auditops 0.1.0__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.
- auditops/core/__init__.py +17 -0
- auditops/core/evidence/reader.py +30 -0
- auditops/core/evidence/writer.py +23 -0
- auditops/core/exclusions.py +109 -0
- auditops/core/models.py +238 -0
- auditops/core/reporting/pdf_report_builder.py +263 -0
- auditops/core/reporting/styles.py +60 -0
- auditops/core/uploader.py +28 -0
- auditops/core/utils.py +122 -0
- auditops/providers/aws/__init__.py +5 -0
- auditops/providers/aws/aws_collector.py +114 -0
- auditops/providers/aws/aws_config.py +76 -0
- auditops/providers/aws/aws_tester.py +89 -0
- auditops/providers/aws/collectors/__init__.py +15 -0
- auditops/providers/aws/collectors/account.py +8 -0
- auditops/providers/aws/collectors/apigateway.py +16 -0
- auditops/providers/aws/collectors/cloudtrail.py +21 -0
- auditops/providers/aws/collectors/ec2.py +51 -0
- auditops/providers/aws/collectors/elbv2.py +16 -0
- auditops/providers/aws/collectors/guardduty.py +26 -0
- auditops/providers/aws/collectors/iam.py +83 -0
- auditops/providers/aws/collectors/lmbda.py +38 -0
- auditops/providers/aws/collectors/rds.py +22 -0
- auditops/providers/aws/collectors/s3.py +51 -0
- auditops/providers/aws/collectors/wafv2.py +26 -0
- auditops/providers/aws/tests/cloudtrail/__init__.py +3 -0
- auditops/providers/aws/tests/cloudtrail/multi_region.py +43 -0
- auditops/providers/aws/tests/ebs/__init__.py +5 -0
- auditops/providers/aws/tests/ebs/default_encryption.py +40 -0
- auditops/providers/aws/tests/ebs/volume_encryption.py +45 -0
- auditops/providers/aws/tests/ebs/volume_tags.py +52 -0
- auditops/providers/aws/tests/ec2/__init__.py +5 -0
- auditops/providers/aws/tests/ec2/instance_tags.py +52 -0
- auditops/providers/aws/tests/ec2/security_group_tags.py +51 -0
- auditops/providers/aws/tests/guardduty/__init__.py +3 -0
- auditops/providers/aws/tests/guardduty/enabled.py +61 -0
- auditops/providers/aws/tests/iam/__init__.py +7 -0
- auditops/providers/aws/tests/iam/password_policy.py +76 -0
- auditops/providers/aws/tests/iam/root_access_key.py +31 -0
- auditops/providers/aws/tests/iam/root_mfa.py +29 -0
- auditops/providers/aws/tests/iam/user_access_key_age.py +80 -0
- auditops/providers/aws/tests/iam/user_mfa.py +60 -0
- auditops/providers/aws/tests/lmbda/__init__.py +3 -0
- auditops/providers/aws/tests/lmbda/tags.py +56 -0
- auditops/providers/aws/tests/rds/__init__.py +9 -0
- auditops/providers/aws/tests/rds/auto_minor_version_upgrade.py +45 -0
- auditops/providers/aws/tests/rds/backup_retention.py +48 -0
- auditops/providers/aws/tests/rds/deletion_protection.py +68 -0
- auditops/providers/aws/tests/rds/encryption.py +45 -0
- auditops/providers/aws/tests/rds/public_access.py +46 -0
- auditops/providers/aws/tests/rds/tags.py +51 -0
- auditops/providers/aws/tests/s3/__init__.py +7 -0
- auditops/providers/aws/tests/s3/encryption.py +51 -0
- auditops/providers/aws/tests/s3/public_access.py +54 -0
- auditops/providers/aws/tests/s3/secure_transport.py +71 -0
- auditops/providers/aws/tests/s3/tags.py +59 -0
- auditops/providers/github/__init__.py +4 -0
- auditops/providers/github/github_collector.py +62 -0
- auditops/providers/github/github_tester.py +33 -0
- auditops/providers/github/tests/org.py +57 -0
- auditops/providers/github/tests/repos.py +37 -0
- auditops/providers/google_workspace/__init__.py +3 -0
- auditops/providers/google_workspace/google_workspace_collector.py +28 -0
- auditops-0.1.0.dist-info/METADATA +41 -0
- auditops-0.1.0.dist-info/RECORD +68 -0
- auditops-0.1.0.dist-info/WHEEL +5 -0
- auditops-0.1.0.dist-info/licenses/LICENSE +21 -0
- auditops-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from .evidence.writer import EvidenceWriter
|
|
4
|
+
from .evidence.reader import EvidenceReader
|
|
5
|
+
from .models import Test, Audit
|
|
6
|
+
from .uploader import Uploader
|
|
7
|
+
from .reporting.pdf_report_builder import PDFReportBuilder
|
|
8
|
+
from .exclusions import ExclusionManager
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"EvidenceWriter",
|
|
12
|
+
"EvidenceReader",
|
|
13
|
+
"Uploader",
|
|
14
|
+
"PDFReportBuilder",
|
|
15
|
+
"Test",
|
|
16
|
+
"ExclusionManager"
|
|
17
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class EvidenceReader:
|
|
6
|
+
def __init__(self, root_dir="tmp"):
|
|
7
|
+
self.root_dir = Path(root_dir)
|
|
8
|
+
self.evidence_dir = self.root_dir / "audit_evidence"
|
|
9
|
+
|
|
10
|
+
def _path(self, relative_path):
|
|
11
|
+
return self.evidence_dir / relative_path
|
|
12
|
+
|
|
13
|
+
def read_json(self, relative_path, optional=False):
|
|
14
|
+
"""
|
|
15
|
+
Read a JSON evidence file.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
relative_path: Path relative from the audit's evidence folder.
|
|
19
|
+
optional: Return None instead of raising if the file is missing.
|
|
20
|
+
"""
|
|
21
|
+
path = self._path(relative_path)
|
|
22
|
+
|
|
23
|
+
if not path.exists():
|
|
24
|
+
if optional:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
raise FileNotFoundError(f"Missing required evidence: {relative_path}")
|
|
28
|
+
|
|
29
|
+
with path.open() as f:
|
|
30
|
+
return json.load(f)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
logging.basicConfig(
|
|
6
|
+
level=logging.INFO,
|
|
7
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
class EvidenceWriter:
|
|
11
|
+
def __init__(self, root_dir="tmp"):
|
|
12
|
+
self.root_dir = Path(root_dir)
|
|
13
|
+
self.evidence_dir = self.root_dir / "audit_evidence"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def save_json(self, relative_path, data):
|
|
17
|
+
if data:
|
|
18
|
+
file_path = self.evidence_dir / relative_path
|
|
19
|
+
|
|
20
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
with file_path.open("w") as f:
|
|
23
|
+
json.dump(data, f, indent=4, default=str)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import fnmatch
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Exclusion:
|
|
10
|
+
provider: str
|
|
11
|
+
test_id: str
|
|
12
|
+
rationale: str
|
|
13
|
+
sample_id: dict | None = None
|
|
14
|
+
expires: date | None = None
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def is_expired(self) -> bool:
|
|
18
|
+
return self.expires is not None and self.expires < date.today()
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def is_pattern(self) -> bool:
|
|
22
|
+
return self.sample_id is not None and any(
|
|
23
|
+
"*" in str(v) for v in self.sample_id.values()
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def matches_sample(self, sample_id: dict) -> bool:
|
|
27
|
+
"""Only valid for pattern exclusions."""
|
|
28
|
+
for field, pattern in self.sample_id.items():
|
|
29
|
+
value = sample_id.get(field)
|
|
30
|
+
if value is None or not fnmatch.fnmatch(str(value), str(pattern)):
|
|
31
|
+
return False
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ExclusionManager:
|
|
36
|
+
def __init__(self, exclusions: list[Exclusion] | None = None):
|
|
37
|
+
self._test_exclusions: dict[tuple, Exclusion] = {}
|
|
38
|
+
self._sample_exclusions: dict[tuple, Exclusion] = {}
|
|
39
|
+
self._sample_patterns: dict[tuple, list[Exclusion]] = defaultdict(list)
|
|
40
|
+
|
|
41
|
+
for exclusion in exclusions or []:
|
|
42
|
+
if exclusion.is_expired:
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
key_prefix = (exclusion.provider, exclusion.test_id)
|
|
46
|
+
|
|
47
|
+
if exclusion.sample_id is None:
|
|
48
|
+
self._test_exclusions[key_prefix] = exclusion
|
|
49
|
+
elif exclusion.is_pattern:
|
|
50
|
+
self._sample_patterns[key_prefix].append(exclusion)
|
|
51
|
+
else:
|
|
52
|
+
key = key_prefix + (frozenset(exclusion.sample_id.items()),)
|
|
53
|
+
self._sample_exclusions[key] = exclusion
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def load_exclusions(cls, filename: str | Path):
|
|
57
|
+
filename = Path(filename)
|
|
58
|
+
|
|
59
|
+
if not filename.exists():
|
|
60
|
+
return cls()
|
|
61
|
+
|
|
62
|
+
with open(filename, "r") as f:
|
|
63
|
+
data = json.load(f)
|
|
64
|
+
|
|
65
|
+
exclusions = []
|
|
66
|
+
for item in data.get("exclusions", []):
|
|
67
|
+
expires = item.get("expires")
|
|
68
|
+
if expires:
|
|
69
|
+
expires = date.fromisoformat(expires)
|
|
70
|
+
|
|
71
|
+
exclusions.append(
|
|
72
|
+
Exclusion(
|
|
73
|
+
provider=item["provider"].lower(),
|
|
74
|
+
test_id=item["test_id"],
|
|
75
|
+
sample_id=item.get("sample_id"),
|
|
76
|
+
rationale=item["rationale"],
|
|
77
|
+
expires=expires,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return cls(exclusions)
|
|
82
|
+
|
|
83
|
+
def get_test_exclusion(self, provider: str, test_id: str) -> Exclusion | None:
|
|
84
|
+
return self._test_exclusions.get(
|
|
85
|
+
(provider.lower(), test_id)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def get_sample_exclusion(self, provider: str, test_id: str, sample_id: dict) -> Exclusion | None:
|
|
89
|
+
provider = provider.lower()
|
|
90
|
+
|
|
91
|
+
# Exact match first (fast)
|
|
92
|
+
key = (provider.lower(), test_id, frozenset(sample_id.items()))
|
|
93
|
+
|
|
94
|
+
exclusion = self._sample_exclusions.get(key)
|
|
95
|
+
if exclusion:
|
|
96
|
+
return exclusion
|
|
97
|
+
|
|
98
|
+
# Pattern match — only checks patterns registered under this (provider, test_id)
|
|
99
|
+
for exclusion in self._sample_patterns.get((provider, test_id), []):
|
|
100
|
+
if exclusion.matches_sample(sample_id):
|
|
101
|
+
return exclusion
|
|
102
|
+
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def is_test_excluded(self, provider: str, test_id: str) -> bool:
|
|
106
|
+
return self.get_test_exclusion(provider, test_id) is not None
|
|
107
|
+
|
|
108
|
+
def is_sample_excluded(self, provider: str, test_id: str, sample_id: dict) -> bool:
|
|
109
|
+
return self.get_sample_exclusion(provider, test_id, sample_id) is not None
|
auditops/core/models.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import List, Optional, Dict, Any
|
|
3
|
+
from datetime import date
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from .reporting.pdf_report_builder import PDFReportBuilder
|
|
7
|
+
from .evidence.reader import EvidenceReader
|
|
8
|
+
from .evidence.writer import EvidenceWriter
|
|
9
|
+
from .exclusions import ExclusionManager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class AuditHelpers:
|
|
14
|
+
reader: EvidenceReader
|
|
15
|
+
writer: EvidenceWriter
|
|
16
|
+
exclusions: ExclusionManager
|
|
17
|
+
report_builder: PDFReportBuilder
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def create(cls, exclusions_file: str | None = None):
|
|
21
|
+
return cls(
|
|
22
|
+
reader=EvidenceReader(),
|
|
23
|
+
writer=EvidenceWriter(),
|
|
24
|
+
exclusions=(
|
|
25
|
+
ExclusionManager.load_exclusions(exclusions_file)
|
|
26
|
+
if exclusions_file
|
|
27
|
+
else ExclusionManager()
|
|
28
|
+
),
|
|
29
|
+
report_builder=PDFReportBuilder(),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class AuditContext:
|
|
35
|
+
provider: str
|
|
36
|
+
helpers: AuditHelpers
|
|
37
|
+
|
|
38
|
+
config: object | None = None
|
|
39
|
+
evidence_folder: str = ""
|
|
40
|
+
report_name: str = ""
|
|
41
|
+
auditor_name: str = "AJ Dehn"
|
|
42
|
+
delete_cached_evidence: bool = True
|
|
43
|
+
summary_mode: bool = False
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def reader(self):
|
|
47
|
+
return self.helpers.reader
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def writer(self):
|
|
51
|
+
return self.helpers.writer
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def exclusions(self):
|
|
55
|
+
return self.helpers.exclusions
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def report_builder(self):
|
|
59
|
+
return self.helpers.report_builder
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def report_dir(self) -> Path:
|
|
63
|
+
path = Path(self.reader.root_dir) / "reports"
|
|
64
|
+
if self.evidence_folder:
|
|
65
|
+
path /= self.evidence_folder
|
|
66
|
+
return path
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def json_report_path(self) -> Path:
|
|
70
|
+
return self.report_dir / f"{self.report_name}.json"
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def pdf_report_path(self) -> Path:
|
|
74
|
+
return self.report_dir / f"{self.report_name}.pdf"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Audit:
|
|
78
|
+
def __init__(self, title=None, auditor_name="AuditOps"):
|
|
79
|
+
self.test_results = None
|
|
80
|
+
self.title = title
|
|
81
|
+
self.auditor_name = auditor_name
|
|
82
|
+
self.scope = None
|
|
83
|
+
|
|
84
|
+
def update_scope(self, updated_scope):
|
|
85
|
+
self.scope = updated_scope
|
|
86
|
+
|
|
87
|
+
def get_scope(self):
|
|
88
|
+
return self.scope
|
|
89
|
+
|
|
90
|
+
def get_scope_formatted(self):
|
|
91
|
+
html = []
|
|
92
|
+
|
|
93
|
+
for item in self.scope:
|
|
94
|
+
if ":" in item:
|
|
95
|
+
label, value = item.split(":", 1)
|
|
96
|
+
html.append(f"<b>{label}:</b> {value}")
|
|
97
|
+
else:
|
|
98
|
+
html.append(item)
|
|
99
|
+
|
|
100
|
+
html_output = "<br/>".join(html)
|
|
101
|
+
|
|
102
|
+
return html_output
|
|
103
|
+
|
|
104
|
+
def to_dict(self):
|
|
105
|
+
return {
|
|
106
|
+
"metadata": {
|
|
107
|
+
"scope": self.scope,
|
|
108
|
+
"report_date": datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
|
109
|
+
},
|
|
110
|
+
"test_results": [t.to_dict() for t in self.test_results]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# NOTE: Samples default to "is_passing: False" until logic determines sample passes the testing criteria.
|
|
115
|
+
@dataclass
|
|
116
|
+
class Sample:
|
|
117
|
+
sample_id: Dict[str, Any]
|
|
118
|
+
is_excluded: bool = False
|
|
119
|
+
is_passing: bool = False
|
|
120
|
+
comments: str = ""
|
|
121
|
+
|
|
122
|
+
def __str__(self):
|
|
123
|
+
return (
|
|
124
|
+
f"sample_id: {self.sample_id}\n"
|
|
125
|
+
f"is_excluded: {self.is_excluded}\n"
|
|
126
|
+
f"is_passing: {self.is_passing}\n"
|
|
127
|
+
f"comments: {self.comments}\n"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def to_dict(self):
|
|
131
|
+
return {
|
|
132
|
+
"sample_id": self.sample_id,
|
|
133
|
+
"is_excluded": self.is_excluded,
|
|
134
|
+
"is_passing": self.is_passing,
|
|
135
|
+
"comments": self.comments,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# NOTE: Tests default to "is_passing: True" until there is a failing sample or other logic determines the test has failed.
|
|
140
|
+
@dataclass
|
|
141
|
+
class Test:
|
|
142
|
+
test_id: str
|
|
143
|
+
test_description: str
|
|
144
|
+
test_procedures: List[str]
|
|
145
|
+
test_attributes: List[str]
|
|
146
|
+
# Rating Matrix: 0 - Informational, 1 - Low, 2 - Medium, 3 - High.
|
|
147
|
+
risk_rating: int
|
|
148
|
+
table_headers: Optional[List[str]] = None
|
|
149
|
+
samples: List["Sample"] = field(default_factory=list)
|
|
150
|
+
is_passing: bool = True
|
|
151
|
+
is_excluded: bool = False
|
|
152
|
+
comments: str = ""
|
|
153
|
+
num_findings: int = 0
|
|
154
|
+
num_exclusions: int = 0
|
|
155
|
+
num_passing: int = 0
|
|
156
|
+
total_population: int = 0
|
|
157
|
+
|
|
158
|
+
def __str__(self):
|
|
159
|
+
return (
|
|
160
|
+
f"test_id: {self.test_id}\n"
|
|
161
|
+
f"test_description: {self.test_description}\n"
|
|
162
|
+
f"risk_rating: {self.risk_rating}\n"
|
|
163
|
+
f"is_passing: {self.is_passing}\n"
|
|
164
|
+
f"comments: {self.comments}\n"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def to_dict(self):
|
|
169
|
+
result = {
|
|
170
|
+
"test_id": self.test_id,
|
|
171
|
+
"is_excluded": self.is_excluded,
|
|
172
|
+
"test_description": self.test_description,
|
|
173
|
+
"risk_rating": self.risk_rating,
|
|
174
|
+
"is_passing": self.is_passing,
|
|
175
|
+
"comments": self.comments,
|
|
176
|
+
"test_procedures": self.test_procedures,
|
|
177
|
+
"test_attributes": self.test_attributes,
|
|
178
|
+
}
|
|
179
|
+
# Include samples, if present.
|
|
180
|
+
if self.samples:
|
|
181
|
+
result["samples"] = [s.to_dict() for s in self.samples]
|
|
182
|
+
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def get_risk_rating_str(self):
|
|
187
|
+
if self.risk_rating == 0: return "Informational"
|
|
188
|
+
elif self.risk_rating == 1: return "Low"
|
|
189
|
+
elif self.risk_rating == 2: return "Medium"
|
|
190
|
+
elif self.risk_rating == 3: return "High"
|
|
191
|
+
else:
|
|
192
|
+
raise ValueError(f"Invalid risk rating: {self.risk_rating}. Accepted values are 0 - 3.")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def add_sample(self, sample):
|
|
196
|
+
self.samples.append(sample)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def evaluate_samples(self, exclusions=None, provider=None, test_id = None, failure_message: str = None):
|
|
200
|
+
self.total_population = len(self.samples)
|
|
201
|
+
self.num_exclusions = 0
|
|
202
|
+
self.num_findings = 0
|
|
203
|
+
self.num_passing = 0
|
|
204
|
+
|
|
205
|
+
for sample in self.samples:
|
|
206
|
+
if exclusions:
|
|
207
|
+
exclusion = exclusions.get_sample_exclusion(provider, self.test_id, sample.sample_id)
|
|
208
|
+
if exclusion:
|
|
209
|
+
sample.is_excluded = True
|
|
210
|
+
sample.comments = exclusion.rationale
|
|
211
|
+
|
|
212
|
+
if sample.is_excluded:
|
|
213
|
+
self.num_exclusions += 1
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
if not sample.is_passing:
|
|
217
|
+
self.num_findings += 1
|
|
218
|
+
|
|
219
|
+
self.is_passing = self.num_findings == 0
|
|
220
|
+
|
|
221
|
+
self.num_passing = self.total_population - self.num_findings - self.num_exclusions
|
|
222
|
+
|
|
223
|
+
if failure_message:
|
|
224
|
+
self.set_failure_summary(failure_message)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def set_failure_summary(self, message: str):
|
|
228
|
+
if self.is_passing:
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
# Make sure message ends with a period.
|
|
232
|
+
message = message.rstrip()
|
|
233
|
+
if not message.endswith("."):
|
|
234
|
+
message += "."
|
|
235
|
+
|
|
236
|
+
self.comments = (
|
|
237
|
+
f"Exceptions Noted. {self.num_findings} of {self.total_population} {message}"
|
|
238
|
+
)
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from reportlab.lib.pagesizes import LETTER
|
|
4
|
+
from reportlab.platypus import (
|
|
5
|
+
SimpleDocTemplate, Table, Paragraph, Spacer, PageBreak, KeepTogether, Image)
|
|
6
|
+
from reportlab.lib.styles import getSampleStyleSheet
|
|
7
|
+
from .styles import (LABEL_STYLE, VALUE_STYLE, LIST_STYLE, CENTER_STYLE, PASS_COLOR,
|
|
8
|
+
FAIL_COLOR, TABLE_STYLE_HIGHLIGHT_ROW, TABLE_STYLE_HIGHLIGHT_COLUMN)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PDFReportBuilder:
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self.styles = getSampleStyleSheet()
|
|
14
|
+
self.page_width, _ = LETTER
|
|
15
|
+
|
|
16
|
+
def _format_pct(self, match_count, test_count):
|
|
17
|
+
# Formats percentage with one decimal point (ex. 99.9%)
|
|
18
|
+
if test_count == 0:
|
|
19
|
+
return f"0%"
|
|
20
|
+
pct = (match_count / test_count) * 100
|
|
21
|
+
return f"{pct:.1f}%"
|
|
22
|
+
|
|
23
|
+
def _label(self, text, style=LABEL_STYLE):
|
|
24
|
+
return Paragraph(text, style)
|
|
25
|
+
|
|
26
|
+
def _value(self, text, style=VALUE_STYLE):
|
|
27
|
+
return Paragraph(str(text), style)
|
|
28
|
+
|
|
29
|
+
def _logo(self, logo_path, width=300):
|
|
30
|
+
logo = Image(logo_path)
|
|
31
|
+
aspect = logo.imageHeight / logo.imageWidth
|
|
32
|
+
logo.drawWidth = width
|
|
33
|
+
logo.drawHeight = width * aspect
|
|
34
|
+
|
|
35
|
+
return logo
|
|
36
|
+
|
|
37
|
+
def _table(self, data, col_widths=None, style=None,
|
|
38
|
+
h_align="LEFT", v_align="TOP"):
|
|
39
|
+
table = Table(data, colWidths=col_widths, hAlign=h_align, vAlign=v_align)
|
|
40
|
+
|
|
41
|
+
if style:
|
|
42
|
+
table.setStyle(style)
|
|
43
|
+
|
|
44
|
+
return table
|
|
45
|
+
|
|
46
|
+
def _status_paragraph(self, passed, style=VALUE_STYLE):
|
|
47
|
+
color = PASS_COLOR if passed else FAIL_COLOR
|
|
48
|
+
text = "Pass" if passed else "Fail"
|
|
49
|
+
return self._value(f"<font color='{color}'>{text}</font>", style=style)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build(self, audit, filename, summary_mode=False):
|
|
53
|
+
"""Generate a PDF audit report."""
|
|
54
|
+
|
|
55
|
+
# Sort test results based on risk-rating.
|
|
56
|
+
tests = sorted(audit.test_results, key=lambda t: (t.is_passing, -t.risk_rating))
|
|
57
|
+
|
|
58
|
+
doc = SimpleDocTemplate(
|
|
59
|
+
filename,
|
|
60
|
+
pagesize= LETTER,
|
|
61
|
+
title= audit.title,
|
|
62
|
+
author= "AuditOps",
|
|
63
|
+
subject= f"Audit report findings and testing instruction for reperformance.",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
elements = []
|
|
67
|
+
|
|
68
|
+
# Add logo
|
|
69
|
+
logo_path = str(Path(__file__).resolve().parent / "assets" / "logo.png")
|
|
70
|
+
elements.append(self._logo(logo_path))
|
|
71
|
+
elements.append(Spacer(1, 18))
|
|
72
|
+
|
|
73
|
+
# Add title
|
|
74
|
+
elements.append(self._value(f"{audit.title}", style=self.styles["Title"]))
|
|
75
|
+
elements.append(Spacer(1, 18))
|
|
76
|
+
|
|
77
|
+
# Add cover page table
|
|
78
|
+
elements.append(self._render_cover_page_table(audit))
|
|
79
|
+
elements.append(Spacer(1, 18))
|
|
80
|
+
|
|
81
|
+
# Add test results summary (includes all tests in the audit)
|
|
82
|
+
header_text = '<a name="test_summary_header"/>Test Summary'
|
|
83
|
+
elements.append(Paragraph(header_text, self.styles['Heading1']))
|
|
84
|
+
|
|
85
|
+
#elements.append(Paragraph("Test Summary", self.styles["Heading1"]))
|
|
86
|
+
elements.append(Spacer(1, 12))
|
|
87
|
+
elements.append(self._render_test_summary_table(tests))
|
|
88
|
+
elements.append(PageBreak())
|
|
89
|
+
|
|
90
|
+
for test in tests:
|
|
91
|
+
# Internal link definition using href="#anchor_name"
|
|
92
|
+
# Anchor for this test
|
|
93
|
+
anchor = test.test_id.replace(" ", "_")
|
|
94
|
+
link_text = (
|
|
95
|
+
f'<a name="{anchor}"/>'
|
|
96
|
+
f'<a href="#test_summary_header" color="blue">'
|
|
97
|
+
f'<b>BACK TO TEST SUMMARY</b></a>'
|
|
98
|
+
)
|
|
99
|
+
#link_text = f'<a href="#test_summary_header" name="{anchor}" color="blue"><b>BACK TO TEST SUMMARY</b></a>'
|
|
100
|
+
elements.append(Paragraph(link_text, self.styles['Normal']))
|
|
101
|
+
|
|
102
|
+
if not test.is_excluded:
|
|
103
|
+
# Build individual test summary
|
|
104
|
+
elements.append(Spacer(1, 18))
|
|
105
|
+
elements.append(KeepTogether(self._render_test_details_table(test)))
|
|
106
|
+
elements.append(Spacer(1, 12))
|
|
107
|
+
if test.table_headers:
|
|
108
|
+
# Build sample table
|
|
109
|
+
elements.append(self._render_test_sample_table(test, summary_mode=summary_mode))
|
|
110
|
+
elements.append(PageBreak())
|
|
111
|
+
|
|
112
|
+
doc.build(elements)
|
|
113
|
+
|
|
114
|
+
def _render_cover_page_table(self, audit):
|
|
115
|
+
test_count = len(audit.test_results)
|
|
116
|
+
failed = sum(not t.is_passing for t in audit.test_results)
|
|
117
|
+
passed = test_count - failed
|
|
118
|
+
rows = [
|
|
119
|
+
("Prepared By", audit.auditor_name),
|
|
120
|
+
("Report Date", datetime.now(timezone.utc).strftime("%Y-%m-%d")),
|
|
121
|
+
("Tests", test_count),
|
|
122
|
+
("Passed", f"{passed} ({self._format_pct(passed, test_count)})"),
|
|
123
|
+
("Failed", f"{failed} ({self._format_pct(failed, test_count)})"),
|
|
124
|
+
("Scope", audit.get_scope_formatted()),
|
|
125
|
+
]
|
|
126
|
+
metadata = [
|
|
127
|
+
[self._label(k), self._value(v)]
|
|
128
|
+
for k, v in rows
|
|
129
|
+
]
|
|
130
|
+
return self._table(metadata, col_widths=[150, 200], style=TABLE_STYLE_HIGHLIGHT_COLUMN, h_align="CENTER")
|
|
131
|
+
|
|
132
|
+
def _render_test_summary_table(self, tests):
|
|
133
|
+
# Creates a table summarizing the results of all tests performed in the audit.
|
|
134
|
+
rows = [[
|
|
135
|
+
self._label("Test"),
|
|
136
|
+
self._label("Result"),
|
|
137
|
+
self._label("Risk"),
|
|
138
|
+
self._label("Comments"),
|
|
139
|
+
]]
|
|
140
|
+
|
|
141
|
+
for test in tests:
|
|
142
|
+
# Creates a link to each test summary.
|
|
143
|
+
anchor = test.test_id.replace(" ", "_")
|
|
144
|
+
test_desc_str = (
|
|
145
|
+
f'<a href="#{anchor}" color="blue"><b>{test.test_id}</b></a>: '
|
|
146
|
+
f'{test.test_description}'
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
"""
|
|
150
|
+
TODO: Consider removing full summary (population, passing, failing, excluded from passing tests.)
|
|
151
|
+
if test.num_exclusions > 0:
|
|
152
|
+
# Add transparency for the number of exclusions.
|
|
153
|
+
if not test.comments:
|
|
154
|
+
# No comments have been populated. Add note for transparency.
|
|
155
|
+
if test.num_exclusions > 1:
|
|
156
|
+
test.comments = f"<b>NOTE:</b> {test.num_exclusions} samples were excluded by management."
|
|
157
|
+
else:
|
|
158
|
+
test.comments = f"<b>NOTE:</b> {test.num_exclusions} sample was excluded by management."
|
|
159
|
+
else:
|
|
160
|
+
if test.num_exclusions > 1:
|
|
161
|
+
test.comments = test.comments + f"<br/><br/><b>NOTE:</b> {test.num_exclusions} samples were excluded by management."
|
|
162
|
+
else:
|
|
163
|
+
test.comments = test.comments + f"<br/><br/><b>NOTE:</b> {test.num_exclusions} sample was excluded by management."
|
|
164
|
+
"""
|
|
165
|
+
new_row = [
|
|
166
|
+
self._value(test_desc_str),
|
|
167
|
+
self._value("Excluded") if test.is_excluded else self._status_paragraph(test.is_passing),
|
|
168
|
+
self._value(test.get_risk_rating_str()),
|
|
169
|
+
# self._value(test.comments)
|
|
170
|
+
self._value(self._create_test_summary_formatted(test))
|
|
171
|
+
]
|
|
172
|
+
rows.append(new_row)
|
|
173
|
+
|
|
174
|
+
return self._table(rows, col_widths=[220, 60, 70, 140], style=TABLE_STYLE_HIGHLIGHT_ROW)
|
|
175
|
+
|
|
176
|
+
def _render_test_details_table(self, test):
|
|
177
|
+
test_procedures = [
|
|
178
|
+
self._value(f"{i+1}. {item}", LIST_STYLE)
|
|
179
|
+
for i, item in enumerate(test.test_procedures)
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
# Build summary table
|
|
183
|
+
table_data = [
|
|
184
|
+
[self._label("Test ID"), self._value(test.test_id)],
|
|
185
|
+
[self._label("Test Description"), self._value(test.test_description)],
|
|
186
|
+
[self._label("Risk Rating"), self._value(test.get_risk_rating_str())],
|
|
187
|
+
[self._label("Test Procedures"), test_procedures],
|
|
188
|
+
[self._label("Conclusion"), self._status_paragraph(test.is_passing)],
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
if test.test_attributes:
|
|
192
|
+
test_attributes = [
|
|
193
|
+
self._value(f"• {item}", LIST_STYLE)
|
|
194
|
+
for item in test.test_attributes
|
|
195
|
+
]
|
|
196
|
+
# Add test attributes only when populated.
|
|
197
|
+
table_data.insert(4, [self._label("Test Attributes"), test_attributes])
|
|
198
|
+
|
|
199
|
+
# Add row to summary table if test failed and comments is populated.
|
|
200
|
+
if not test.is_passing and test.comments:
|
|
201
|
+
table_data.append([self._label("Comments"), self._value(test.comments)])
|
|
202
|
+
|
|
203
|
+
table_width = self.page_width - 2 * 72
|
|
204
|
+
return self._table(table_data, col_widths=[table_width * 0.25, table_width * 0.75],
|
|
205
|
+
style=TABLE_STYLE_HIGHLIGHT_COLUMN)
|
|
206
|
+
|
|
207
|
+
def _render_test_sample_table(self, test, summary_mode=False):
|
|
208
|
+
# Sort failing samples to top of the table.
|
|
209
|
+
samples = sorted(test.samples, key=lambda s: (s.is_passing, s.is_excluded))
|
|
210
|
+
#samples = sorted(test.samples, key=lambda s: (s.is_passing))
|
|
211
|
+
|
|
212
|
+
table_data = []
|
|
213
|
+
# Build header row (Ex. ["Bucket Name", "Results", "Comments]")
|
|
214
|
+
table_data.append([self._label(h) for h in test.table_headers])
|
|
215
|
+
for i, sample in enumerate(samples, 1):
|
|
216
|
+
row = []
|
|
217
|
+
for val in sample.sample_id.values():
|
|
218
|
+
if summary_mode:
|
|
219
|
+
row.append(self._value(f"Sample: {i}"))
|
|
220
|
+
else:
|
|
221
|
+
row.append(self._value(val))
|
|
222
|
+
|
|
223
|
+
# Document Result
|
|
224
|
+
if sample.is_excluded:
|
|
225
|
+
row.append(self._value("Excluded", style=CENTER_STYLE))
|
|
226
|
+
row.append(self._value(str(sample.comments)))
|
|
227
|
+
else:
|
|
228
|
+
row.append(self._status_paragraph(sample.is_passing, CENTER_STYLE))
|
|
229
|
+
if not sample.is_passing:
|
|
230
|
+
# Add comments if sample failed.
|
|
231
|
+
row.append(self._value(str(sample.comments)))
|
|
232
|
+
|
|
233
|
+
table_data.append(row)
|
|
234
|
+
|
|
235
|
+
table_width = self.page_width - 2 * 72
|
|
236
|
+
col_width = table_width / len(table_data[0]) # divide evenly across columns
|
|
237
|
+
col_widths = [col_width] * len(table_data[0])
|
|
238
|
+
return self._table(table_data, col_widths=col_widths, style=TABLE_STYLE_HIGHLIGHT_ROW)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _create_test_summary_formatted(self, test):
|
|
243
|
+
test_summary = ""
|
|
244
|
+
|
|
245
|
+
"""
|
|
246
|
+
if test.is_passing:
|
|
247
|
+
test_summary = "No Exceptions Noted."
|
|
248
|
+
else:
|
|
249
|
+
test_summary = "Exceptions Noted."
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
if test.total_population > 0:
|
|
253
|
+
test_summary += f"- Population: {test.total_population}"
|
|
254
|
+
passing_pct = self._format_pct(test.num_passing, test.total_population)
|
|
255
|
+
test_summary += f"<br/>- Passing: {test.num_passing} ({passing_pct})"
|
|
256
|
+
if test.num_findings > 0:
|
|
257
|
+
failing_pct = self._format_pct(test.num_findings, test.total_population)
|
|
258
|
+
test_summary += f"<br/>- Failing: {test.num_findings} ({failing_pct})"
|
|
259
|
+
if test.num_exclusions > 0:
|
|
260
|
+
exclusion_pct = self._format_pct(test.num_exclusions, test.total_population)
|
|
261
|
+
test_summary += f"<br/>- Excluded: {test.num_exclusions} ({exclusion_pct})"
|
|
262
|
+
|
|
263
|
+
return test_summary
|