Skip to content

Commit 36b48aa

Browse files
author
Peter Wagstaff
committed
Added zero padding to controls <10
1 parent 19a0e06 commit 36b48aa

6 files changed

Lines changed: 4773 additions & 4440 deletions

File tree

nist_catalog/nist_sp_800-53_control_catalog.json

Lines changed: 4181 additions & 4181 deletions
Large diffs are not rendered by default.

nist_catalog/nist_zero_padder.py

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
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"\nSample 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"\nSample 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("\nNo updates were needed - all control IDs already have proper formatting.")
271+
else:
272+
print(f"\nZero-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

Comments
 (0)