1+ #!/usr/bin/env python3
2+ """
3+ NIST SP 800-53 Control ID Zero Padder
4+
5+ This script modifies the NIST SP 800-53 control catalog JSON file to add zero-padding
6+ to single-digit control numbers and enhancement numbers.
7+
8+ Examples of transformations:
9+ - SC-1 → SC-01
10+ - AC-4(1) → AC-04(01)
11+ - PE-12(5) → PE-12(05)
12+ - AT-3 → AT-03
13+
14+ Usage:
15+ python nist_zero_padder.py input_file.json [output_file.json]
16+
17+ If no output file is specified, the script will overwrite the input file.
18+ """
19+
20+ import json
21+ import re
22+ import sys
23+ from pathlib import Path
24+ from typing import Dict , List , Any , Tuple
25+
26+
27+ def pad_control_id (control_id : str ) -> str :
28+ """
29+ Add zero-padding to single-digit control and enhancement numbers.
30+
31+ Args:
32+ control_id: Original control ID like "SC-1", "AC-4(1)", etc.
33+
34+ Returns:
35+ Zero-padded control ID like "SC-01", "AC-04(01)", etc.
36+ """
37+ # Match patterns like "AC-1" or "AC-1(1)"
38+ match = re .match (r'^([A-Z]{2,3})-(\d+)(?:\((\d+)\))?$' , control_id )
39+
40+ if not match :
41+ print (f"Warning: Invalid control ID format, skipping: { control_id } " , file = sys .stderr )
42+ return control_id
43+
44+ family = match .group (1 )
45+ base_number = match .group (2 ).zfill (2 ) # Zero-pad to 2 digits
46+ enhancement_number = match .group (3 )
47+
48+ if enhancement_number :
49+ enhancement_number = enhancement_number .zfill (2 ) # Zero-pad to 2 digits
50+ return f"{ family } -{ base_number } ({ enhancement_number } )"
51+ else :
52+ return f"{ family } -{ base_number } "
53+
54+
55+ def update_base_control_id (base_control_id : str ) -> str :
56+ """
57+ Update baseControlId field with zero-padding.
58+
59+ Args:
60+ base_control_id: Original base control ID like "SC-1"
61+
62+ Returns:
63+ Zero-padded base control ID like "SC-01"
64+ """
65+ # Base control IDs should not have enhancements
66+ match = re .match (r'^([A-Z]{2,3})-(\d+)$' , base_control_id )
67+
68+ if not match :
69+ print (f"Warning: Invalid base control ID format, skipping: { base_control_id } " , file = sys .stderr )
70+ return base_control_id
71+
72+ family = match .group (1 )
73+ base_number = match .group (2 ).zfill (2 ) # Zero-pad to 2 digits
74+
75+ return f"{ family } -{ base_number } "
76+
77+
78+ def process_controls (controls : List [Dict [str , Any ]]) -> Tuple [List [Dict [str , Any ]], Dict [str , int ]]:
79+ """
80+ Process all controls to add zero-padding to their IDs.
81+
82+ Args:
83+ controls: List of control dictionaries
84+
85+ Returns:
86+ Tuple of (updated_controls, statistics)
87+ """
88+ updated_controls = []
89+ stats = {
90+ 'total_processed' : 0 ,
91+ 'ids_updated' : 0 ,
92+ 'base_control_ids_updated' : 0 ,
93+ 'related_controls_updated' : 0 ,
94+ 'errors' : 0
95+ }
96+
97+ for control in controls :
98+ stats ['total_processed' ] += 1
99+ updated_control = control .copy ()
100+
101+ try :
102+ # Update main control ID
103+ original_id = control .get ('id' , '' )
104+ if original_id :
105+ new_id = pad_control_id (original_id )
106+ if new_id != original_id :
107+ updated_control ['id' ] = new_id
108+ stats ['ids_updated' ] += 1
109+
110+ # Update baseControlId if present
111+ original_base_id = control .get ('baseControlId' , '' )
112+ if original_base_id :
113+ new_base_id = update_base_control_id (original_base_id )
114+ if new_base_id != original_base_id :
115+ updated_control ['baseControlId' ] = new_base_id
116+ stats ['base_control_ids_updated' ] += 1
117+
118+ # Update relatedControls if present
119+ related_controls = control .get ('relatedControls' , [])
120+ if related_controls and isinstance (related_controls , list ):
121+ updated_related = []
122+ related_updated = False
123+
124+ for related_id in related_controls :
125+ if isinstance (related_id , str ):
126+ new_related_id = pad_control_id (related_id )
127+ if new_related_id != related_id :
128+ related_updated = True
129+ updated_related .append (new_related_id )
130+ else :
131+ # Keep non-string values as-is
132+ updated_related .append (related_id )
133+
134+ if related_updated :
135+ updated_control ['relatedControls' ] = updated_related
136+ stats ['related_controls_updated' ] += 1
137+
138+ updated_controls .append (updated_control )
139+
140+ except Exception as e :
141+ print (f"Error processing control { control .get ('id' , 'unknown' )} : { e } " , file = sys .stderr )
142+ updated_controls .append (control ) # Keep original on error
143+ stats ['errors' ] += 1
144+
145+ return updated_controls , stats
146+
147+
148+ def validate_json_structure (data : Dict [str , Any ]) -> None :
149+ """
150+ Validate that the JSON has the expected structure.
151+
152+ Args:
153+ data: Parsed JSON data
154+
155+ Raises:
156+ ValueError: If structure is not as expected
157+ """
158+ if not isinstance (data , dict ):
159+ raise ValueError ("JSON root must be an object" )
160+
161+ if 'controls' not in data :
162+ raise ValueError ("JSON must contain a 'controls' key" )
163+
164+ if not isinstance (data ['controls' ], list ):
165+ raise ValueError ("'controls' must be an array" )
166+
167+
168+ def print_processing_summary (stats : Dict [str , int ],
169+ sample_updates : List [Tuple [str , str ]],
170+ sample_related_updates : List [Tuple [str , List [str ], List [str ]]]) -> None :
171+ """Print a summary of the zero-padding operation."""
172+
173+ print (f"Processing Summary:" )
174+ print (f" Total controls processed: { stats ['total_processed' ]} " )
175+ print (f" Control IDs updated: { stats ['ids_updated' ]} " )
176+ print (f" Base control IDs updated: { stats ['base_control_ids_updated' ]} " )
177+ print (f" Controls with updated related controls: { stats ['related_controls_updated' ]} " )
178+
179+ if stats ['errors' ] > 0 :
180+ print (f" Errors encountered: { stats ['errors' ]} " )
181+
182+ if sample_updates :
183+ print (f"\n Sample ID transformations:" )
184+ for original , updated in sample_updates [:10 ]: # Show first 10
185+ print (f" { original } → { updated } " )
186+
187+ if len (sample_updates ) > 10 :
188+ print (f" ... and { len (sample_updates ) - 10 } more" )
189+
190+ if sample_related_updates :
191+ print (f"\n Sample related controls transformations:" )
192+ for control_id , original_related , updated_related in sample_related_updates [:5 ]: # Show first 5
193+ print (f" { control_id } :" )
194+ print (f" Before: { original_related } " )
195+ print (f" After: { updated_related } " )
196+
197+ if len (sample_related_updates ) > 5 :
198+ print (f" ... and { len (sample_related_updates ) - 5 } more controls with related controls updates" )
199+
200+
201+ def collect_sample_updates (original_controls : List [Dict [str , Any ]],
202+ updated_controls : List [Dict [str , Any ]]) -> Tuple [List [Tuple [str , str ]], List [Tuple [str , List [str ], List [str ]]]]:
203+ """Collect sample updates for display."""
204+ id_samples = []
205+ related_samples = []
206+
207+ for orig , updated in zip (original_controls , updated_controls ):
208+ # Collect ID updates
209+ orig_id = orig .get ('id' , '' )
210+ new_id = updated .get ('id' , '' )
211+
212+ if orig_id != new_id :
213+ id_samples .append ((orig_id , new_id ))
214+
215+ # Collect related controls updates
216+ orig_related = orig .get ('relatedControls' , [])
217+ new_related = updated .get ('relatedControls' , [])
218+
219+ if orig_related != new_related and orig_related and new_related :
220+ control_id = updated .get ('id' , orig .get ('id' , 'unknown' ))
221+ related_samples .append ((control_id , orig_related , new_related ))
222+
223+ return id_samples , related_samples
224+
225+
226+ def main ():
227+ """Main function."""
228+ if len (sys .argv ) < 2 :
229+ print ("Usage: python nist_zero_padder.py input_file.json [output_file.json]" )
230+ sys .exit (1 )
231+
232+ input_file = Path (sys .argv [1 ])
233+ output_file = Path (sys .argv [2 ]) if len (sys .argv ) > 2 else input_file
234+
235+ if not input_file .exists ():
236+ print (f"Error: Input file '{ input_file } ' not found." )
237+ sys .exit (1 )
238+
239+ try :
240+ # Read and parse JSON
241+ print (f"Reading { input_file } ..." )
242+ with open (input_file , 'r' , encoding = 'utf-8' ) as f :
243+ data = json .load (f )
244+
245+ # Validate structure
246+ validate_json_structure (data )
247+
248+ # Get controls array
249+ original_controls = data ['controls' ]
250+ print (f"Found { len (original_controls )} controls to process." )
251+
252+ # Process controls to add zero-padding
253+ print ("Adding zero-padding to control IDs..." )
254+ updated_controls , stats = process_controls (original_controls )
255+
256+ # Update data with processed controls
257+ data ['controls' ] = updated_controls
258+
259+ # Write output
260+ print (f"Writing updated controls to { output_file } ..." )
261+ with open (output_file , 'w' , encoding = 'utf-8' ) as f :
262+ json .dump (data , f , indent = 2 , ensure_ascii = False )
263+
264+ # Collect and print summary
265+ id_samples , related_samples = collect_sample_updates (original_controls , updated_controls )
266+ print_processing_summary (stats , id_samples , related_samples )
267+
268+ total_updates = stats ['ids_updated' ] + stats ['base_control_ids_updated' ] + stats ['related_controls_updated' ]
269+ if total_updates == 0 :
270+ print ("\n No updates were needed - all control IDs already have proper formatting." )
271+ else :
272+ print (f"\n Zero-padding complete! Output saved to: { output_file } " )
273+
274+ except json .JSONDecodeError as e :
275+ print (f"Error: Invalid JSON in '{ input_file } ': { e } " )
276+ sys .exit (1 )
277+ except Exception as e :
278+ print (f"Error: { e } " )
279+ sys .exit (1 )
280+
281+
282+ if __name__ == "__main__" :
283+ main ()
0 commit comments