complio 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- CHANGELOG.md +208 -0
- README.md +343 -0
- complio/__init__.py +48 -0
- complio/cli/__init__.py +0 -0
- complio/cli/banner.py +87 -0
- complio/cli/commands/__init__.py +0 -0
- complio/cli/commands/history.py +439 -0
- complio/cli/commands/scan.py +700 -0
- complio/cli/main.py +115 -0
- complio/cli/output.py +338 -0
- complio/config/__init__.py +17 -0
- complio/config/settings.py +333 -0
- complio/connectors/__init__.py +9 -0
- complio/connectors/aws/__init__.py +0 -0
- complio/connectors/aws/client.py +342 -0
- complio/connectors/base.py +135 -0
- complio/core/__init__.py +10 -0
- complio/core/registry.py +228 -0
- complio/core/runner.py +351 -0
- complio/py.typed +0 -0
- complio/reporters/__init__.py +7 -0
- complio/reporters/generator.py +417 -0
- complio/tests_library/__init__.py +0 -0
- complio/tests_library/base.py +492 -0
- complio/tests_library/identity/__init__.py +0 -0
- complio/tests_library/identity/access_key_rotation.py +302 -0
- complio/tests_library/identity/mfa_enforcement.py +327 -0
- complio/tests_library/identity/root_account_protection.py +470 -0
- complio/tests_library/infrastructure/__init__.py +0 -0
- complio/tests_library/infrastructure/cloudtrail_encryption.py +286 -0
- complio/tests_library/infrastructure/cloudtrail_log_validation.py +274 -0
- complio/tests_library/infrastructure/cloudtrail_logging.py +400 -0
- complio/tests_library/infrastructure/ebs_encryption.py +244 -0
- complio/tests_library/infrastructure/ec2_security_groups.py +321 -0
- complio/tests_library/infrastructure/iam_password_policy.py +460 -0
- complio/tests_library/infrastructure/nacl_security.py +356 -0
- complio/tests_library/infrastructure/rds_encryption.py +252 -0
- complio/tests_library/infrastructure/s3_encryption.py +301 -0
- complio/tests_library/infrastructure/s3_public_access.py +369 -0
- complio/tests_library/infrastructure/secrets_manager_encryption.py +248 -0
- complio/tests_library/infrastructure/vpc_flow_logs.py +287 -0
- complio/tests_library/logging/__init__.py +0 -0
- complio/tests_library/logging/cloudwatch_alarms.py +354 -0
- complio/tests_library/logging/cloudwatch_logs_encryption.py +281 -0
- complio/tests_library/logging/cloudwatch_retention.py +252 -0
- complio/tests_library/logging/config_enabled.py +393 -0
- complio/tests_library/logging/eventbridge_rules.py +460 -0
- complio/tests_library/logging/guardduty_enabled.py +436 -0
- complio/tests_library/logging/security_hub_enabled.py +416 -0
- complio/tests_library/logging/sns_encryption.py +273 -0
- complio/tests_library/network/__init__.py +0 -0
- complio/tests_library/network/alb_nlb_security.py +421 -0
- complio/tests_library/network/api_gateway_security.py +452 -0
- complio/tests_library/network/cloudfront_https.py +332 -0
- complio/tests_library/network/direct_connect_security.py +343 -0
- complio/tests_library/network/nacl_configuration.py +367 -0
- complio/tests_library/network/network_firewall.py +355 -0
- complio/tests_library/network/transit_gateway_security.py +318 -0
- complio/tests_library/network/vpc_endpoints_security.py +339 -0
- complio/tests_library/network/vpn_security.py +333 -0
- complio/tests_library/network/waf_configuration.py +428 -0
- complio/tests_library/security/__init__.py +0 -0
- complio/tests_library/security/kms_key_rotation.py +314 -0
- complio/tests_library/storage/__init__.py +0 -0
- complio/tests_library/storage/backup_encryption.py +288 -0
- complio/tests_library/storage/dynamodb_encryption.py +280 -0
- complio/tests_library/storage/efs_encryption.py +257 -0
- complio/tests_library/storage/elasticache_encryption.py +370 -0
- complio/tests_library/storage/redshift_encryption.py +252 -0
- complio/tests_library/storage/s3_versioning.py +264 -0
- complio/utils/__init__.py +26 -0
- complio/utils/errors.py +179 -0
- complio/utils/exceptions.py +151 -0
- complio/utils/history.py +243 -0
- complio/utils/logger.py +391 -0
- complio-0.1.1.dist-info/METADATA +385 -0
- complio-0.1.1.dist-info/RECORD +79 -0
- complio-0.1.1.dist-info/WHEEL +4 -0
- complio-0.1.1.dist-info/entry_points.txt +3 -0
complio/utils/history.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scan history tracking for Complio.
|
|
3
|
+
|
|
4
|
+
This module manages local storage of scan results to allow users to:
|
|
5
|
+
- View recent scans
|
|
6
|
+
- Compare scan results over time
|
|
7
|
+
- Track compliance improvements
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
>>> from complio.utils.history import save_scan_to_history, get_scan_history
|
|
11
|
+
>>> save_scan_to_history(scan_id, results)
|
|
12
|
+
>>> history = get_scan_history(limit=10)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
from complio.core.runner import ScanResults
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# History directory location
|
|
25
|
+
SCAN_HISTORY_DIR = Path.home() / ".complio" / "history"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ensure_history_dir() -> Path:
|
|
29
|
+
"""Ensure scan history directory exists with proper permissions.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Path to history directory
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
>>> history_dir = ensure_history_dir()
|
|
36
|
+
>>> print(history_dir)
|
|
37
|
+
PosixPath('/home/user/.complio/history')
|
|
38
|
+
"""
|
|
39
|
+
SCAN_HISTORY_DIR.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
# Set directory permissions to 700 (owner only)
|
|
41
|
+
SCAN_HISTORY_DIR.chmod(0o700)
|
|
42
|
+
return SCAN_HISTORY_DIR
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save_scan_to_history(scan_id: str, results: ScanResults, region: str) -> Path:
|
|
46
|
+
"""Save scan results to local history.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
scan_id: Unique scan identifier
|
|
50
|
+
results: Scan results to save
|
|
51
|
+
region: AWS region that was scanned
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Path to saved history file
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
>>> save_scan_to_history("scan_20260107_162335_abc123", results, "eu-west-3")
|
|
58
|
+
PosixPath('/home/user/.complio/history/scan_20260107_162335_abc123.json')
|
|
59
|
+
"""
|
|
60
|
+
history_dir = ensure_history_dir()
|
|
61
|
+
filepath = history_dir / f"{scan_id}.json"
|
|
62
|
+
|
|
63
|
+
# Prepare history data
|
|
64
|
+
history_data = {
|
|
65
|
+
"scan_id": scan_id,
|
|
66
|
+
"timestamp": datetime.fromtimestamp(results.timestamp).isoformat(),
|
|
67
|
+
"region": region,
|
|
68
|
+
"summary": {
|
|
69
|
+
"total_tests": results.total_tests,
|
|
70
|
+
"passed_tests": results.passed_tests,
|
|
71
|
+
"failed_tests": results.failed_tests,
|
|
72
|
+
"error_tests": results.error_tests,
|
|
73
|
+
"overall_score": results.overall_score,
|
|
74
|
+
"compliance_status": "COMPLIANT" if results.overall_score >= 90 else "NON_COMPLIANT",
|
|
75
|
+
"execution_time_seconds": round(results.execution_time, 2),
|
|
76
|
+
},
|
|
77
|
+
"test_results": [
|
|
78
|
+
{
|
|
79
|
+
"test_id": tr.test_id,
|
|
80
|
+
"test_name": tr.test_name,
|
|
81
|
+
"status": tr.status,
|
|
82
|
+
"passed": tr.passed,
|
|
83
|
+
"score": tr.score,
|
|
84
|
+
"findings_count": len(tr.findings),
|
|
85
|
+
}
|
|
86
|
+
for tr in results.test_results
|
|
87
|
+
],
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Save to file
|
|
91
|
+
with open(filepath, 'w') as f:
|
|
92
|
+
json.dump(history_data, f, indent=2)
|
|
93
|
+
|
|
94
|
+
# Set file permissions to 600 (owner read/write only)
|
|
95
|
+
filepath.chmod(0o600)
|
|
96
|
+
|
|
97
|
+
return filepath
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_scan_history(limit: int = 10) -> List[Dict[str, Any]]:
|
|
101
|
+
"""Get list of recent scans from history.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
limit: Maximum number of scans to return (default: 10)
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
List of scan history entries, most recent first
|
|
108
|
+
|
|
109
|
+
Example:
|
|
110
|
+
>>> history = get_scan_history(limit=5)
|
|
111
|
+
>>> for scan in history:
|
|
112
|
+
... print(f"{scan['timestamp']}: {scan['summary']['overall_score']}%")
|
|
113
|
+
"""
|
|
114
|
+
if not SCAN_HISTORY_DIR.exists():
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
# Get all scan files
|
|
118
|
+
scan_files = sorted(
|
|
119
|
+
SCAN_HISTORY_DIR.glob("scan_*.json"),
|
|
120
|
+
key=lambda p: p.stat().st_mtime,
|
|
121
|
+
reverse=True # Most recent first
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
history = []
|
|
125
|
+
for scan_file in scan_files[:limit]:
|
|
126
|
+
try:
|
|
127
|
+
with open(scan_file, 'r') as f:
|
|
128
|
+
data = json.load(f)
|
|
129
|
+
history.append(data)
|
|
130
|
+
except (json.JSONDecodeError, KeyError):
|
|
131
|
+
# Skip corrupted files
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
return history
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_scan_by_id(scan_id: str) -> Optional[Dict[str, Any]]:
|
|
138
|
+
"""Get a specific scan by its ID.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
scan_id: Scan identifier to retrieve
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Scan data dictionary, or None if not found
|
|
145
|
+
|
|
146
|
+
Example:
|
|
147
|
+
>>> scan = get_scan_by_id("scan_20260107_162335_abc123")
|
|
148
|
+
>>> if scan:
|
|
149
|
+
... print(scan['summary']['overall_score'])
|
|
150
|
+
"""
|
|
151
|
+
filepath = SCAN_HISTORY_DIR / f"{scan_id}.json"
|
|
152
|
+
|
|
153
|
+
if not filepath.exists():
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
with open(filepath, 'r') as f:
|
|
158
|
+
return json.load(f)
|
|
159
|
+
except (json.JSONDecodeError, IOError):
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def compare_scans(scan_id_1: str, scan_id_2: str) -> Dict[str, Any]:
|
|
164
|
+
"""Compare two scans and return the differences.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
scan_id_1: First scan ID
|
|
168
|
+
scan_id_2: Second scan ID
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Dictionary containing comparison data
|
|
172
|
+
|
|
173
|
+
Example:
|
|
174
|
+
>>> diff = compare_scans("scan_20260107_...", "scan_20260106_...")
|
|
175
|
+
>>> print(f"Score change: {diff['score_change']}%")
|
|
176
|
+
"""
|
|
177
|
+
scan1 = get_scan_by_id(scan_id_1)
|
|
178
|
+
scan2 = get_scan_by_id(scan_id_2)
|
|
179
|
+
|
|
180
|
+
if not scan1 or not scan2:
|
|
181
|
+
raise ValueError(f"One or both scans not found in history")
|
|
182
|
+
|
|
183
|
+
# Calculate differences
|
|
184
|
+
score1 = scan1['summary']['overall_score']
|
|
185
|
+
score2 = scan2['summary']['overall_score']
|
|
186
|
+
score_change = score1 - score2
|
|
187
|
+
|
|
188
|
+
passed1 = scan1['summary']['passed_tests']
|
|
189
|
+
passed2 = scan2['summary']['passed_tests']
|
|
190
|
+
|
|
191
|
+
failed1 = scan1['summary']['failed_tests']
|
|
192
|
+
failed2 = scan2['summary']['failed_tests']
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
"scan1": {
|
|
196
|
+
"scan_id": scan1['scan_id'],
|
|
197
|
+
"timestamp": scan1['timestamp'],
|
|
198
|
+
"score": score1,
|
|
199
|
+
"passed": passed1,
|
|
200
|
+
"failed": failed1,
|
|
201
|
+
},
|
|
202
|
+
"scan2": {
|
|
203
|
+
"scan_id": scan2['scan_id'],
|
|
204
|
+
"timestamp": scan2['timestamp'],
|
|
205
|
+
"score": score2,
|
|
206
|
+
"passed": passed2,
|
|
207
|
+
"failed": failed2,
|
|
208
|
+
},
|
|
209
|
+
"differences": {
|
|
210
|
+
"score_change": score_change,
|
|
211
|
+
"score_change_direction": "improved" if score_change > 0 else "declined" if score_change < 0 else "unchanged",
|
|
212
|
+
"passed_change": passed1 - passed2,
|
|
213
|
+
"failed_change": failed1 - failed2,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def clear_old_history(keep_days: int = 30) -> int:
|
|
219
|
+
"""Clear scan history older than specified days.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
keep_days: Number of days to keep (default: 30)
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Number of files deleted
|
|
226
|
+
|
|
227
|
+
Example:
|
|
228
|
+
>>> deleted = clear_old_history(keep_days=30)
|
|
229
|
+
>>> print(f"Deleted {deleted} old scans")
|
|
230
|
+
"""
|
|
231
|
+
if not SCAN_HISTORY_DIR.exists():
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
from datetime import timedelta
|
|
235
|
+
cutoff_time = datetime.now().timestamp() - (keep_days * 86400)
|
|
236
|
+
|
|
237
|
+
deleted_count = 0
|
|
238
|
+
for scan_file in SCAN_HISTORY_DIR.glob("scan_*.json"):
|
|
239
|
+
if scan_file.stat().st_mtime < cutoff_time:
|
|
240
|
+
scan_file.unlink()
|
|
241
|
+
deleted_count += 1
|
|
242
|
+
|
|
243
|
+
return deleted_count
|
complio/utils/logger.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured logging with credential filtering.
|
|
3
|
+
|
|
4
|
+
This module provides secure, structured logging for Complio with automatic
|
|
5
|
+
credential redaction. All sensitive data is filtered before being written to logs.
|
|
6
|
+
|
|
7
|
+
Security Features:
|
|
8
|
+
- Automatic credential redaction (AWS keys, secrets, passwords)
|
|
9
|
+
- Structured JSON logging for machine parsing
|
|
10
|
+
- Log rotation to prevent disk fill
|
|
11
|
+
- Separate console and file logging
|
|
12
|
+
- Configurable log levels
|
|
13
|
+
- Context binding for request tracking
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
>>> from complio.utils.logger import get_logger
|
|
17
|
+
>>> logger = get_logger(__name__)
|
|
18
|
+
>>> logger.info("test_started", test_name="s3_encryption", region="us-east-1")
|
|
19
|
+
>>> # Credentials automatically redacted:
|
|
20
|
+
>>> logger.info("aws_response", access_key="AKIAIOSFODNN7EXAMPLE")
|
|
21
|
+
>>> # Logged as: access_key="[REDACTED_AWS_ACCESS_KEY]"
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import re
|
|
26
|
+
from logging.handlers import RotatingFileHandler
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Dict, Optional
|
|
29
|
+
|
|
30
|
+
import structlog
|
|
31
|
+
from structlog.processors import JSONRenderer
|
|
32
|
+
from structlog.stdlib import add_log_level, filter_by_level
|
|
33
|
+
|
|
34
|
+
from complio.config.settings import get_settings
|
|
35
|
+
|
|
36
|
+
# ============================================================================
|
|
37
|
+
# CREDENTIAL PATTERNS (for redaction)
|
|
38
|
+
# ============================================================================
|
|
39
|
+
|
|
40
|
+
# AWS Access Key patterns
|
|
41
|
+
AWS_ACCESS_KEY_PATTERN = re.compile(r"(AKIA[0-9A-Z]{16})", re.IGNORECASE)
|
|
42
|
+
AWS_SECRET_KEY_PATTERN = re.compile(r"([A-Za-z0-9/+=]{40})", re.IGNORECASE)
|
|
43
|
+
AWS_SESSION_TOKEN_PATTERN = re.compile(r"(FwoGZXIvYXdzE[A-Za-z0-9/+=]{100,})", re.IGNORECASE)
|
|
44
|
+
|
|
45
|
+
# Generic password patterns
|
|
46
|
+
PASSWORD_PATTERN = re.compile(
|
|
47
|
+
r'(password["\s:=]+)([^"\s,}]+)',
|
|
48
|
+
re.IGNORECASE
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# API keys and tokens
|
|
52
|
+
API_KEY_PATTERN = re.compile(
|
|
53
|
+
r'(["\s](api[_-]?key|token|secret)["\s:=]+)([^"\s,}]+)',
|
|
54
|
+
re.IGNORECASE
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Private IP addresses (optional - may want to keep these)
|
|
58
|
+
PRIVATE_IP_PATTERN = re.compile(
|
|
59
|
+
r'\b(?:10|172\.(?:1[6-9]|2[0-9]|3[01])|192\.168)\.\d{1,3}\.\d{1,3}\b'
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Email addresses (optional - may want to keep these)
|
|
63
|
+
EMAIL_PATTERN = re.compile(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b')
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ============================================================================
|
|
67
|
+
# CREDENTIAL FILTERING PROCESSOR
|
|
68
|
+
# ============================================================================
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def filter_credentials(logger: Any, method_name: str, event_dict: Dict[str, Any]) -> Dict[str, Any]:
|
|
72
|
+
"""Filter sensitive credentials from log events.
|
|
73
|
+
|
|
74
|
+
This processor scans all log event data and redacts any credentials,
|
|
75
|
+
passwords, API keys, or other sensitive information.
|
|
76
|
+
|
|
77
|
+
Redaction Rules:
|
|
78
|
+
- AWS Access Keys (AKIA...) → [REDACTED_AWS_ACCESS_KEY]
|
|
79
|
+
- AWS Secret Keys (40 chars) → [REDACTED_AWS_SECRET_KEY]
|
|
80
|
+
- AWS Session Tokens → [REDACTED_AWS_SESSION_TOKEN]
|
|
81
|
+
- AWS Account IDs (12 digits) → ****1234 (last 4 digits only)
|
|
82
|
+
- User IDs → ****W53X (last 4 chars only)
|
|
83
|
+
- Passwords → [REDACTED_PASSWORD]
|
|
84
|
+
- API Keys/Tokens → [REDACTED_API_KEY]
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
logger: Logger instance
|
|
88
|
+
method_name: Log method name (info, error, etc.)
|
|
89
|
+
event_dict: Log event dictionary
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Event dictionary with credentials redacted
|
|
93
|
+
|
|
94
|
+
Example:
|
|
95
|
+
>>> event = {"msg": "Connected", "access_key": "AKIAIOSFODNN7EXAMPLE"}
|
|
96
|
+
>>> filtered = filter_credentials(None, "info", event)
|
|
97
|
+
>>> print(filtered["access_key"])
|
|
98
|
+
'[REDACTED_AWS_ACCESS_KEY]'
|
|
99
|
+
"""
|
|
100
|
+
# Process all values in the event dict
|
|
101
|
+
for key, value in event_dict.items():
|
|
102
|
+
if isinstance(value, str):
|
|
103
|
+
# Redact AWS Access Keys
|
|
104
|
+
value = AWS_ACCESS_KEY_PATTERN.sub("[REDACTED_AWS_ACCESS_KEY]", value)
|
|
105
|
+
|
|
106
|
+
# Redact AWS Secret Keys (be careful with this - 40 chars is common)
|
|
107
|
+
# Only redact if key name suggests it's a secret
|
|
108
|
+
if any(secret_key in key.lower() for secret_key in ["secret", "password", "token"]):
|
|
109
|
+
if len(value) >= 40:
|
|
110
|
+
value = "[REDACTED_AWS_SECRET_KEY]"
|
|
111
|
+
|
|
112
|
+
# Redact AWS Session Tokens
|
|
113
|
+
value = AWS_SESSION_TOKEN_PATTERN.sub("[REDACTED_AWS_SESSION_TOKEN]", value)
|
|
114
|
+
|
|
115
|
+
# Mask AWS Account IDs (show last 4 digits only)
|
|
116
|
+
if any(acct_key in key.lower() for acct_key in ["account_id", "account"]):
|
|
117
|
+
if value.isdigit() and len(value) == 12:
|
|
118
|
+
value = f"****{value[-4:]}"
|
|
119
|
+
|
|
120
|
+
# Mask User IDs (show last 4 chars only)
|
|
121
|
+
if any(user_key in key.lower() for user_key in ["user_id", "userid"]):
|
|
122
|
+
if len(value) > 4:
|
|
123
|
+
value = f"****{value[-4:]}"
|
|
124
|
+
|
|
125
|
+
# Redact passwords
|
|
126
|
+
value = PASSWORD_PATTERN.sub(r'\1[REDACTED_PASSWORD]', value)
|
|
127
|
+
|
|
128
|
+
# Redact API keys
|
|
129
|
+
value = API_KEY_PATTERN.sub(r'\1[REDACTED_API_KEY]', value)
|
|
130
|
+
|
|
131
|
+
# Update the value
|
|
132
|
+
event_dict[key] = value
|
|
133
|
+
|
|
134
|
+
elif isinstance(value, dict):
|
|
135
|
+
# Recursively filter nested dicts
|
|
136
|
+
event_dict[key] = _filter_dict(value)
|
|
137
|
+
|
|
138
|
+
elif isinstance(value, list):
|
|
139
|
+
# Filter lists
|
|
140
|
+
event_dict[key] = [_filter_value(item) for item in value]
|
|
141
|
+
|
|
142
|
+
return event_dict
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _filter_dict(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
146
|
+
"""Recursively filter credentials from dictionary.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
data: Dictionary to filter
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Filtered dictionary
|
|
153
|
+
"""
|
|
154
|
+
filtered = {}
|
|
155
|
+
for key, value in data.items():
|
|
156
|
+
if isinstance(value, str):
|
|
157
|
+
# Check for sensitive keys
|
|
158
|
+
if any(sensitive in key.lower() for sensitive in [
|
|
159
|
+
"password", "secret", "token", "key", "credential"
|
|
160
|
+
]):
|
|
161
|
+
filtered[key] = "[REDACTED]"
|
|
162
|
+
else:
|
|
163
|
+
# Still scan for patterns
|
|
164
|
+
filtered[key] = AWS_ACCESS_KEY_PATTERN.sub("[REDACTED_AWS_ACCESS_KEY]", value)
|
|
165
|
+
filtered[key] = AWS_SESSION_TOKEN_PATTERN.sub("[REDACTED_AWS_SESSION_TOKEN]", filtered[key])
|
|
166
|
+
elif isinstance(value, dict):
|
|
167
|
+
filtered[key] = _filter_dict(value)
|
|
168
|
+
elif isinstance(value, list):
|
|
169
|
+
filtered[key] = [_filter_value(item) for item in value]
|
|
170
|
+
else:
|
|
171
|
+
filtered[key] = value
|
|
172
|
+
return filtered
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _filter_value(value: Any) -> Any:
|
|
176
|
+
"""Filter a single value.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
value: Value to filter
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Filtered value
|
|
183
|
+
"""
|
|
184
|
+
if isinstance(value, str):
|
|
185
|
+
value = AWS_ACCESS_KEY_PATTERN.sub("[REDACTED_AWS_ACCESS_KEY]", value)
|
|
186
|
+
value = AWS_SESSION_TOKEN_PATTERN.sub("[REDACTED_AWS_SESSION_TOKEN]", value)
|
|
187
|
+
return value
|
|
188
|
+
elif isinstance(value, dict):
|
|
189
|
+
return _filter_dict(value)
|
|
190
|
+
elif isinstance(value, list):
|
|
191
|
+
return [_filter_value(item) for item in value]
|
|
192
|
+
else:
|
|
193
|
+
return value
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ============================================================================
|
|
197
|
+
# LOGGER SETUP
|
|
198
|
+
# ============================================================================
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def setup_logging() -> None:
|
|
202
|
+
"""Configure structured logging for the application.
|
|
203
|
+
|
|
204
|
+
Sets up:
|
|
205
|
+
- Console handler with colored output (if enabled)
|
|
206
|
+
- File handler with rotation (if enabled)
|
|
207
|
+
- Credential filtering processor
|
|
208
|
+
- JSON formatting for file logs
|
|
209
|
+
- Structured event dict format
|
|
210
|
+
|
|
211
|
+
Called automatically when getting a logger.
|
|
212
|
+
|
|
213
|
+
Example:
|
|
214
|
+
>>> setup_logging()
|
|
215
|
+
>>> logger = logging.getLogger("complio")
|
|
216
|
+
>>> logger.info("Application started")
|
|
217
|
+
"""
|
|
218
|
+
settings = get_settings()
|
|
219
|
+
|
|
220
|
+
# Get log level
|
|
221
|
+
log_level = getattr(logging, settings.log_level)
|
|
222
|
+
|
|
223
|
+
# Configure stdlib logging
|
|
224
|
+
logging.basicConfig(
|
|
225
|
+
format="%(message)s",
|
|
226
|
+
level=log_level,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Silence noisy libraries
|
|
230
|
+
logging.getLogger("boto3").setLevel(logging.WARNING)
|
|
231
|
+
logging.getLogger("botocore").setLevel(logging.WARNING)
|
|
232
|
+
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
|
233
|
+
|
|
234
|
+
# Build processor chain
|
|
235
|
+
shared_processors = [
|
|
236
|
+
structlog.stdlib.add_logger_name,
|
|
237
|
+
add_log_level,
|
|
238
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
239
|
+
filter_credentials, # CRITICAL: Filter credentials before any output
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
# Configure structlog
|
|
243
|
+
structlog.configure(
|
|
244
|
+
processors=shared_processors + [
|
|
245
|
+
filter_by_level,
|
|
246
|
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
|
247
|
+
],
|
|
248
|
+
context_class=dict,
|
|
249
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
250
|
+
cache_logger_on_first_use=True,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Set up file logging if enabled
|
|
254
|
+
if settings.log_to_file:
|
|
255
|
+
# Ensure log directory exists
|
|
256
|
+
settings.log_dir.mkdir(parents=True, exist_ok=True)
|
|
257
|
+
settings.log_dir.chmod(0o700)
|
|
258
|
+
|
|
259
|
+
log_file = settings.log_dir / "complio.log"
|
|
260
|
+
|
|
261
|
+
# Create rotating file handler
|
|
262
|
+
file_handler = RotatingFileHandler(
|
|
263
|
+
filename=str(log_file),
|
|
264
|
+
maxBytes=settings.log_max_bytes,
|
|
265
|
+
backupCount=settings.log_backup_count,
|
|
266
|
+
encoding="utf-8",
|
|
267
|
+
)
|
|
268
|
+
file_handler.setLevel(log_level)
|
|
269
|
+
|
|
270
|
+
# Use JSON formatter for file logs
|
|
271
|
+
file_handler.setFormatter(
|
|
272
|
+
structlog.stdlib.ProcessorFormatter(
|
|
273
|
+
processor=JSONRenderer(),
|
|
274
|
+
foreign_pre_chain=shared_processors,
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Add to root logger
|
|
279
|
+
root_logger = logging.getLogger()
|
|
280
|
+
root_logger.addHandler(file_handler)
|
|
281
|
+
|
|
282
|
+
# Set file permissions to 600
|
|
283
|
+
log_file.chmod(0o600)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# Track if logging has been set up
|
|
287
|
+
_logging_configured = False
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
|
291
|
+
"""Get a structured logger instance.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
name: Logger name (usually __name__)
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Structured logger with credential filtering
|
|
298
|
+
|
|
299
|
+
Example:
|
|
300
|
+
>>> logger = get_logger(__name__)
|
|
301
|
+
>>> logger.info("user_login", user_id=123, ip="10.0.0.1")
|
|
302
|
+
>>> logger.error("auth_failed", error="Invalid password")
|
|
303
|
+
"""
|
|
304
|
+
global _logging_configured
|
|
305
|
+
if not _logging_configured:
|
|
306
|
+
setup_logging()
|
|
307
|
+
_logging_configured = True
|
|
308
|
+
|
|
309
|
+
return structlog.get_logger(name)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ============================================================================
|
|
313
|
+
# CONVENIENCE FUNCTIONS
|
|
314
|
+
# ============================================================================
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def log_function_call(logger: structlog.stdlib.BoundLogger, function_name: str, **kwargs: Any) -> None:
|
|
318
|
+
"""Log a function call with parameters.
|
|
319
|
+
|
|
320
|
+
Automatically filters sensitive parameters.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
logger: Logger instance
|
|
324
|
+
function_name: Name of function being called
|
|
325
|
+
**kwargs: Function parameters
|
|
326
|
+
|
|
327
|
+
Example:
|
|
328
|
+
>>> logger = get_logger(__name__)
|
|
329
|
+
>>> log_function_call(logger, "connect_to_aws", region="us-east-1", profile="prod")
|
|
330
|
+
"""
|
|
331
|
+
logger.debug(
|
|
332
|
+
"function_call",
|
|
333
|
+
function=function_name,
|
|
334
|
+
**kwargs
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def log_aws_api_call(
|
|
339
|
+
logger: structlog.stdlib.BoundLogger,
|
|
340
|
+
service: str,
|
|
341
|
+
operation: str,
|
|
342
|
+
region: str,
|
|
343
|
+
**kwargs: Any
|
|
344
|
+
) -> None:
|
|
345
|
+
"""Log an AWS API call.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
logger: Logger instance
|
|
349
|
+
service: AWS service name (e.g., 's3', 'ec2')
|
|
350
|
+
operation: API operation (e.g., 'list_buckets')
|
|
351
|
+
region: AWS region
|
|
352
|
+
**kwargs: Additional context
|
|
353
|
+
|
|
354
|
+
Example:
|
|
355
|
+
>>> logger = get_logger(__name__)
|
|
356
|
+
>>> log_aws_api_call(logger, "s3", "list_buckets", "us-east-1")
|
|
357
|
+
"""
|
|
358
|
+
logger.debug(
|
|
359
|
+
"aws_api_call",
|
|
360
|
+
service=service,
|
|
361
|
+
operation=operation,
|
|
362
|
+
region=region,
|
|
363
|
+
**kwargs
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def log_test_result(
|
|
368
|
+
logger: structlog.stdlib.BoundLogger,
|
|
369
|
+
test_name: str,
|
|
370
|
+
passed: bool,
|
|
371
|
+
**kwargs: Any
|
|
372
|
+
) -> None:
|
|
373
|
+
"""Log a compliance test result.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
logger: Logger instance
|
|
377
|
+
test_name: Name of compliance test
|
|
378
|
+
passed: Whether test passed
|
|
379
|
+
**kwargs: Additional context (findings, resources, etc.)
|
|
380
|
+
|
|
381
|
+
Example:
|
|
382
|
+
>>> logger = get_logger(__name__)
|
|
383
|
+
>>> log_test_result(logger, "s3_encryption", True, bucket_count=5)
|
|
384
|
+
"""
|
|
385
|
+
log_level = "info" if passed else "warning"
|
|
386
|
+
getattr(logger, log_level)(
|
|
387
|
+
"compliance_test_result",
|
|
388
|
+
test_name=test_name,
|
|
389
|
+
passed=passed,
|
|
390
|
+
**kwargs
|
|
391
|
+
)
|