11import json
22import uuid
33from 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
68from prowler .lib .outputs .oscal .models import (
79 DEFAULT_IMPORT_AP_HREF ,
810 AssessmentResults ,
1820
1921
2022class 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