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.
Files changed (68) hide show
  1. auditops/core/__init__.py +17 -0
  2. auditops/core/evidence/reader.py +30 -0
  3. auditops/core/evidence/writer.py +23 -0
  4. auditops/core/exclusions.py +109 -0
  5. auditops/core/models.py +238 -0
  6. auditops/core/reporting/pdf_report_builder.py +263 -0
  7. auditops/core/reporting/styles.py +60 -0
  8. auditops/core/uploader.py +28 -0
  9. auditops/core/utils.py +122 -0
  10. auditops/providers/aws/__init__.py +5 -0
  11. auditops/providers/aws/aws_collector.py +114 -0
  12. auditops/providers/aws/aws_config.py +76 -0
  13. auditops/providers/aws/aws_tester.py +89 -0
  14. auditops/providers/aws/collectors/__init__.py +15 -0
  15. auditops/providers/aws/collectors/account.py +8 -0
  16. auditops/providers/aws/collectors/apigateway.py +16 -0
  17. auditops/providers/aws/collectors/cloudtrail.py +21 -0
  18. auditops/providers/aws/collectors/ec2.py +51 -0
  19. auditops/providers/aws/collectors/elbv2.py +16 -0
  20. auditops/providers/aws/collectors/guardduty.py +26 -0
  21. auditops/providers/aws/collectors/iam.py +83 -0
  22. auditops/providers/aws/collectors/lmbda.py +38 -0
  23. auditops/providers/aws/collectors/rds.py +22 -0
  24. auditops/providers/aws/collectors/s3.py +51 -0
  25. auditops/providers/aws/collectors/wafv2.py +26 -0
  26. auditops/providers/aws/tests/cloudtrail/__init__.py +3 -0
  27. auditops/providers/aws/tests/cloudtrail/multi_region.py +43 -0
  28. auditops/providers/aws/tests/ebs/__init__.py +5 -0
  29. auditops/providers/aws/tests/ebs/default_encryption.py +40 -0
  30. auditops/providers/aws/tests/ebs/volume_encryption.py +45 -0
  31. auditops/providers/aws/tests/ebs/volume_tags.py +52 -0
  32. auditops/providers/aws/tests/ec2/__init__.py +5 -0
  33. auditops/providers/aws/tests/ec2/instance_tags.py +52 -0
  34. auditops/providers/aws/tests/ec2/security_group_tags.py +51 -0
  35. auditops/providers/aws/tests/guardduty/__init__.py +3 -0
  36. auditops/providers/aws/tests/guardduty/enabled.py +61 -0
  37. auditops/providers/aws/tests/iam/__init__.py +7 -0
  38. auditops/providers/aws/tests/iam/password_policy.py +76 -0
  39. auditops/providers/aws/tests/iam/root_access_key.py +31 -0
  40. auditops/providers/aws/tests/iam/root_mfa.py +29 -0
  41. auditops/providers/aws/tests/iam/user_access_key_age.py +80 -0
  42. auditops/providers/aws/tests/iam/user_mfa.py +60 -0
  43. auditops/providers/aws/tests/lmbda/__init__.py +3 -0
  44. auditops/providers/aws/tests/lmbda/tags.py +56 -0
  45. auditops/providers/aws/tests/rds/__init__.py +9 -0
  46. auditops/providers/aws/tests/rds/auto_minor_version_upgrade.py +45 -0
  47. auditops/providers/aws/tests/rds/backup_retention.py +48 -0
  48. auditops/providers/aws/tests/rds/deletion_protection.py +68 -0
  49. auditops/providers/aws/tests/rds/encryption.py +45 -0
  50. auditops/providers/aws/tests/rds/public_access.py +46 -0
  51. auditops/providers/aws/tests/rds/tags.py +51 -0
  52. auditops/providers/aws/tests/s3/__init__.py +7 -0
  53. auditops/providers/aws/tests/s3/encryption.py +51 -0
  54. auditops/providers/aws/tests/s3/public_access.py +54 -0
  55. auditops/providers/aws/tests/s3/secure_transport.py +71 -0
  56. auditops/providers/aws/tests/s3/tags.py +59 -0
  57. auditops/providers/github/__init__.py +4 -0
  58. auditops/providers/github/github_collector.py +62 -0
  59. auditops/providers/github/github_tester.py +33 -0
  60. auditops/providers/github/tests/org.py +57 -0
  61. auditops/providers/github/tests/repos.py +37 -0
  62. auditops/providers/google_workspace/__init__.py +3 -0
  63. auditops/providers/google_workspace/google_workspace_collector.py +28 -0
  64. auditops-0.1.0.dist-info/METADATA +41 -0
  65. auditops-0.1.0.dist-info/RECORD +68 -0
  66. auditops-0.1.0.dist-info/WHEEL +5 -0
  67. auditops-0.1.0.dist-info/licenses/LICENSE +21 -0
  68. 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
@@ -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