Skip to content

Commit 6579466

Browse files
AAH20cursoragent
andcommitted
fix(outputs): address OSCAL review — unique result UUID + real Finding model
Use uuid4() for result UUID so each export is unique, type Finding and read fields directly (OCSF-style), skip muted FAILs as findings, omit empty related-observations, and normalize naive timestamps for schema timezone requirements. Tests now use generate_finding_output. Signed-off-by: Ahmed Hassan <th3reality72@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 9e4b9fc commit 6579466

3 files changed

Lines changed: 120 additions & 139 deletions

File tree

prowler/lib/outputs/oscal/models.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,12 @@ def to_dict(self) -> dict[str, Any]:
109109
"target-id": self.target_id,
110110
"status": target_status,
111111
},
112-
"related-observations": [ro.to_dict() for ro in self.related_observations],
113112
}
113+
# related-observations has minItems: 1 when present — omit if empty.
114+
if self.related_observations:
115+
res["related-observations"] = [
116+
ro.to_dict() for ro in self.related_observations
117+
]
114118
if self.props:
115119
res["props"] = [p.to_dict() for p in self.props]
116120
if self.remarks:

prowler/lib/outputs/oscal/oscal.py

Lines changed: 61 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import json
22
import uuid
33
from datetime import datetime, timezone
4-
from typing import Any, List, Optional
4+
from typing import List, Optional
55

6+
from prowler.lib.outputs.common import Status
7+
from prowler.lib.outputs.finding import Finding
68
from prowler.lib.outputs.oscal.models import (
79
DEFAULT_IMPORT_AP_HREF,
810
AssessmentResults,
@@ -18,20 +20,18 @@
1820

1921

2022
class OSCAL:
21-
"""
22-
Transforms Prowler findings into NIST OSCAL 1.2.3 Assessment Results JSON format.
23-
"""
23+
"""Transforms Prowler findings into NIST OSCAL 1.2.3 Assessment Results JSON."""
2424

2525
def __init__(
2626
self,
27-
findings: List[Any],
27+
findings: List[Finding],
2828
file_path: Optional[str] = None,
2929
import_ap_href: str = DEFAULT_IMPORT_AP_HREF,
3030
) -> None:
3131
"""Build and hold the OSCAL document(s) for the given findings.
3232
3333
Args:
34-
findings: Prowler finding objects to transform.
34+
findings: Prowler ``Finding`` objects to transform.
3535
file_path: Optional output path (used by callers that write
3636
straight to disk rather than via ``batch_write_data_to_file``).
3737
import_ap_href: The assessment-plan reference this run is
@@ -49,23 +49,21 @@ def data(self) -> List[OscalDocument]:
4949
"""The transformed OSCAL document(s), one per ``transform()`` call."""
5050
return self._data
5151

52-
def transform(self, findings: List[Any]) -> None:
53-
"""
54-
Transforms a list of Prowler findings into an OSCAL AssessmentResults document.
52+
def transform(self, findings: List[Finding]) -> None:
53+
"""Transform Prowler findings into an OSCAL AssessmentResults document.
54+
55+
Args:
56+
findings: Non-empty list of ``Finding`` instances for one export.
5557
"""
5658
if not findings:
5759
return
5860

59-
now_iso = datetime.now(timezone.utc).isoformat()
61+
now = datetime.now(timezone.utc)
62+
now_iso = now.isoformat()
6063
first_finding = findings[0]
61-
account_uid = getattr(first_finding, "account_uid", "default-account")
62-
provider_name = (
63-
first_finding.metadata.Provider
64-
if hasattr(first_finding, "metadata")
65-
and hasattr(first_finding.metadata, "Provider")
66-
else "aws"
67-
)
68-
prowler_version = getattr(first_finding, "prowler_version", "4.0.0")
64+
account_uid = first_finding.account_uid
65+
provider_name = first_finding.metadata.Provider
66+
prowler_version = first_finding.prowler_version
6967

7068
metadata = Metadata(
7169
title=f"Prowler Security Assessment — {provider_name.upper()} ({account_uid})",
@@ -85,9 +83,9 @@ def transform(self, findings: List[Any]) -> None:
8583
],
8684
)
8785

88-
result_uuid = str(
89-
uuid.uuid5(uuid.NAMESPACE_DNS, f"prowler.result.{account_uid}")
90-
)
86+
# Result UUID must be unique per export (one assessment run), while
87+
# observation/finding UUIDs stay reproducible from finding.uid.
88+
result_uuid = str(uuid.uuid4())
9189
result = Result(
9290
result_uuid=result_uuid,
9391
title=f"Assessment Results for {provider_name.upper()} Account {account_uid}",
@@ -103,98 +101,83 @@ def transform(self, findings: List[Any]) -> None:
103101
)
104102

105103
for finding in findings:
106-
finding_timestamp = (
107-
finding.timestamp.isoformat()
108-
if hasattr(finding, "timestamp")
109-
and isinstance(finding.timestamp, datetime)
110-
else now_iso
111-
)
112-
resource_uid = getattr(finding, "resource_uid", "resource-default")
113-
resource_name = getattr(finding, "resource_name", resource_uid)
114-
finding_uid = getattr(finding, "uid", str(uuid.uuid4()))
115-
region = getattr(finding, "region", "global")
116-
muted = getattr(finding, "muted", False)
104+
# OSCAL date-time-with-timezone requires a Z / offset; naive
105+
# finding timestamps (common in tests and some providers) must
106+
# be treated as UTC rather than emitted without a zone.
107+
if isinstance(finding.timestamp, datetime):
108+
ts = finding.timestamp
109+
if ts.tzinfo is None:
110+
ts = ts.replace(tzinfo=timezone.utc)
111+
finding_timestamp = ts.isoformat()
112+
else:
113+
finding_timestamp = now_iso
117114
status_val = (
118115
finding.status.value
119116
if hasattr(finding.status, "value")
120117
else str(finding.status)
121118
)
122-
123-
subject_uuid = str(
124-
uuid.uuid5(uuid.NAMESPACE_DNS, f"prowler.resource.{resource_uid}")
119+
severity_val = (
120+
finding.metadata.Severity.value
121+
if hasattr(finding.metadata.Severity, "value")
122+
else str(finding.metadata.Severity)
125123
)
126-
obs_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"prowler.obs.{finding_uid}"))
127124

128-
check_id = (
129-
finding.metadata.CheckID
130-
if hasattr(finding, "metadata") and hasattr(finding.metadata, "CheckID")
131-
else "check_id"
125+
subject_uuid = str(
126+
uuid.uuid5(
127+
uuid.NAMESPACE_DNS, f"prowler.resource.{finding.resource_uid}"
128+
)
132129
)
133-
check_title = (
134-
finding.metadata.CheckTitle
135-
if hasattr(finding, "metadata")
136-
and hasattr(finding.metadata, "CheckTitle")
137-
else check_id
130+
obs_uuid = str(
131+
uuid.uuid5(uuid.NAMESPACE_DNS, f"prowler.obs.{finding.uid}")
138132
)
139-
status_extended = getattr(finding, "status_extended", check_title)
140-
141-
severity_val = "medium"
142-
if hasattr(finding, "metadata") and hasattr(finding.metadata, "Severity"):
143-
sev = finding.metadata.Severity
144-
severity_val = sev.value if hasattr(sev, "value") else str(sev)
145133

146134
subject = Subject(
147135
subject_uuid=subject_uuid,
148136
type="component",
149-
title=resource_name or resource_uid,
150-
description=f"Resource evaluated in region {region}",
137+
title=finding.resource_name or finding.resource_uid,
151138
props=[
152-
Property(name="resource-uid", value=resource_uid),
153-
Property(name="region", value=region),
139+
Property(name="resource-uid", value=finding.resource_uid),
140+
Property(name="region", value=finding.region),
154141
],
155142
)
156143

157144
observation = Observation(
158145
observation_uuid=obs_uuid,
159-
title=f"{check_id}: {status_val}",
160-
description=status_extended,
146+
title=f"{finding.metadata.CheckID}: {status_val}",
147+
description=finding.status_extended,
161148
collected=finding_timestamp,
162149
subjects=[subject],
163150
props=[
164-
Property(name="check-id", value=check_id),
151+
Property(name="check-id", value=finding.metadata.CheckID),
165152
Property(name="status", value=status_val),
166153
Property(name="severity", value=severity_val),
167-
Property(name="muted", value=str(muted).lower()),
168-
Property(name="region", value=region),
154+
Property(name="muted", value=str(finding.muted).lower()),
155+
Property(name="region", value=finding.region),
169156
],
170157
)
171158
result.observations.append(observation)
172159

173-
# Emit OSCAL finding only for FAIL status
174-
if status_val == "FAIL":
160+
# FAIL only, and never muted — muted FAILs stay as observations
161+
# with muted=true so suppressed results do not assert remediation.
162+
if status_val == Status.FAIL and not finding.muted:
175163
find_uuid = str(
176-
uuid.uuid5(uuid.NAMESPACE_DNS, f"prowler.finding.{finding_uid}")
164+
uuid.uuid5(uuid.NAMESPACE_DNS, f"prowler.finding.{finding.uid}")
177165
)
178166
remediation_rec = ""
179-
if hasattr(finding, "metadata") and hasattr(
180-
finding.metadata, "Remediation"
181-
):
182-
rem = finding.metadata.Remediation
183-
if hasattr(rem, "Recommendation") and hasattr(
184-
rem.Recommendation, "Text"
185-
):
186-
remediation_rec = rem.Recommendation.Text
167+
rem = finding.metadata.Remediation
168+
if rem and rem.Recommendation and rem.Recommendation.Text:
169+
remediation_rec = rem.Recommendation.Text
187170

188171
oscal_finding = OscalFinding(
189172
finding_uuid=find_uuid,
190-
title=f"Non-compliant check: {check_id}",
191-
description=status_extended,
192-
target_id=check_id,
173+
title=f"Non-compliant check: {finding.metadata.CheckID}",
174+
description=finding.status_extended,
175+
target_id=finding.metadata.CheckID,
193176
related_observations=[
194177
RelatedObservation(observation_uuid=obs_uuid)
195178
],
196179
props=[
197-
Property(name="check-id", value=check_id),
180+
Property(name="check-id", value=finding.metadata.CheckID),
198181
Property(name="status", value="unsatisfied"),
199182
Property(name="severity", value=severity_val),
200183
],
@@ -206,10 +189,8 @@ def transform(self, findings: List[Any]) -> None:
206189
)
207190
)
208191

209-
# Extract NIST compliance mappings if present
210-
compliance = getattr(finding, "compliance", {})
211-
if isinstance(compliance, dict):
212-
for framework, controls in compliance.items():
192+
if isinstance(finding.compliance, dict):
193+
for framework, controls in finding.compliance.items():
213194
if "nist" in framework.lower() and isinstance(controls, list):
214195
for ctrl in controls:
215196
oscal_finding.props.append(
@@ -233,9 +214,7 @@ def transform(self, findings: List[Any]) -> None:
233214
self._data.append(doc)
234215

235216
def batch_write_data_to_file(self) -> None:
236-
"""
237-
Serializes and writes the OSCAL document to file.
238-
"""
217+
"""Serialize and write the OSCAL document to ``file_descriptor``."""
239218
if self._data and self.file_descriptor:
240219
for doc in self._data:
241220
payload = doc.to_dict()

0 commit comments

Comments
 (0)