1+ #!/usr/bin/env python3
2+ """
3+ CNSSI 1253 PDF Parser
4+ Extracts security controls from CNSSI-1253 PDF and converts to JSON format.
5+ """
6+
7+ import json
8+ import re
9+ import sys
10+ from typing import Dict , List , Optional
11+ import fitz # PyMuPDF
12+
13+
14+ class CNSSIParser :
15+ def __init__ (self , pdf_path : str , debug : bool = False ):
16+ self .pdf_path = pdf_path
17+ self .doc = None
18+ self .controls = []
19+ self .debug = debug
20+
21+ def open_pdf (self ):
22+ """Open the PDF document."""
23+ try :
24+ self .doc = fitz .open (self .pdf_path )
25+ print (f"Successfully opened PDF: { self .pdf_path } " )
26+ print (f"Total pages: { len (self .doc )} " )
27+ except Exception as e :
28+ print (f"Error opening PDF: { e } " )
29+ sys .exit (1 )
30+
31+ def close_pdf (self ):
32+ """Close the PDF document."""
33+ if self .doc :
34+ self .doc .close ()
35+
36+ def is_table_header_row (self , text_line : str ) -> bool :
37+ """Check if a line contains table headers."""
38+ text_upper = text_line .upper ()
39+
40+ if 'ID' in text_upper and 'TITLE' in text_upper :
41+ return True
42+ if 'CONFIDENTIALITY' in text_upper and 'INTEGRITY' in text_upper :
43+ return True
44+ if 'AVAILABILITY' in text_upper :
45+ return True
46+ if text_line .strip () == 'L M H L M H L M H' :
47+ return True
48+ if 'L M H' in text_line and text_line .count ('L M H' ) >= 2 :
49+ return True
50+
51+ # Also look for the start of actual control data
52+ control_id = self .extract_control_id (text_line )
53+ if control_id == 'AC-1' : # First control is usually AC-1
54+ return True
55+
56+ return False
57+
58+ def extract_control_id (self , text : str ) -> Optional [str ]:
59+ """Extract control ID from text (e.g., AC-1, AC-2(1))."""
60+ text_stripped = text .strip ()
61+
62+ # Match patterns like AC-1, AC-2(1), PM-1, etc.
63+ pattern = r'^([A-Z]{2,3}-\d+(?:\(\d+\))?)'
64+ match = re .search (pattern , text_stripped )
65+ if match :
66+ return match .group (1 )
67+
68+ # Alternative pattern - look anywhere in the text
69+ pattern2 = r'([A-Z]{2,3}-\d+(?:\(\d+\))?)'
70+ match = re .search (pattern2 , text )
71+ if match :
72+ start_pos = match .start ()
73+ if start_pos == 0 or text [start_pos - 1 ].isspace ():
74+ return match .group (1 )
75+
76+ return None
77+
78+ def has_selection_markers (self , text : str ) -> bool :
79+ """Check if text contains X or + markers indicating selection."""
80+ pattern = r'(?:^|\s)([X+])(?:\s|$)'
81+ matches = re .findall (pattern , text )
82+ return len (matches ) > 0
83+
84+ def clean_title (self , title : str ) -> str :
85+ """Clean and normalize control title."""
86+ # Remove extra whitespace and normalize
87+ title = ' ' .join (title .split ())
88+ # Remove any trailing markers that might have been included
89+ title = re .sub (r'\s*[X+]\s*$' , '' , title )
90+ return title .strip ()
91+
92+ def is_only_markers (self , text : str ) -> bool :
93+ """Check if line contains only X, +, or whitespace."""
94+ pattern = r'^[X+\s]*$'
95+ return re .match (pattern , text ) is not None
96+
97+ def is_footnote_or_header (self , text : str ) -> bool :
98+ """Check if text is a footnote, header, or other non-control content."""
99+ text_lower = text .lower ()
100+ text_stripped = text .strip ()
101+
102+ # Check if it's just a number
103+ if text_stripped .isdigit ():
104+ return True
105+
106+ # Check for specific footnote text
107+ if 'changes to the security control catalog' in text_lower :
108+ return True
109+ if 'under the authority of nist' in text_lower :
110+ return True
111+ if 'cnssi no.' in text_lower :
112+ return True
113+ if 'appendix' in text_lower :
114+ return True
115+
116+ # Check for page numbers like D-1, D-35
117+ if text .startswith ('D-' ) and len (text ) < 10 :
118+ return True
119+
120+ # Very short text
121+ if len (text_stripped ) < 3 :
122+ return True
123+
124+ # Pure number check
125+ number_pattern = r'^\d+$'
126+ if re .match (number_pattern , text_stripped ):
127+ return True
128+
129+ return False
130+
131+ def should_continue_control_title (self , text : str , current_control_id : str ) -> bool :
132+ """Determine if text should be added to current control's title."""
133+ if self .is_footnote_or_header (text ):
134+ return False
135+
136+ # Don't continue if we hit another control ID
137+ if self .extract_control_id (text ):
138+ return False
139+
140+ # Don't continue if it's only selection markers
141+ if self .is_only_markers (text ):
142+ return False
143+
144+ # Don't continue if it looks like table structure
145+ if 'Confidentiality' in text or 'Integrity' in text or 'Availability' in text :
146+ return False
147+
148+ return True
149+
150+ def extract_controls_from_page (self , page_num : int ) -> List [Dict ]:
151+ """Extract controls from a single page."""
152+ page = self .doc [page_num ]
153+ text = page .get_text ()
154+ lines = text .split ('\n ' )
155+
156+ controls = []
157+ in_table = False
158+ current_control = None
159+ found_first_content_after_header = False
160+
161+ print (f" Page { page_num + 1 } has { len (lines )} lines" )
162+
163+ for i , line in enumerate (lines ):
164+ line = line .strip ()
165+ if not line :
166+ continue
167+
168+ # Debug output
169+ if self .debug and page_num < 3 and i < 50 :
170+ display_line = line [:100 ] + '...' if len (line ) > 100 else line
171+ print (f" Line { i } : '{ display_line } '" )
172+
173+ # Check if we're entering a table or if we find a control
174+ control_id = self .extract_control_id (line )
175+
176+ if self .is_table_header_row (line ):
177+ print (f" Found table header at line { i } : { line } " )
178+ in_table = True
179+ found_first_content_after_header = False
180+ continue
181+
182+ # If we find a control ID, we're definitely in the table area
183+ if control_id :
184+ in_table = True
185+ if self .debug :
186+ print (f" Found control ID (auto-detected table): { control_id } " )
187+
188+ if not in_table :
189+ continue
190+
191+ # Handle the first meaningful content after table headers on a new page
192+ if in_table and not found_first_content_after_header and not control_id :
193+ # This might be a continuation from the previous page
194+ if not self .is_footnote_or_header (line ) and not self .is_only_markers (line ):
195+ cleaned_line = self .clean_title (line )
196+ if cleaned_line :
197+ # Create a continuation control that will be merged later
198+ continuation_control = {
199+ 'id' : 'CONTINUATION' ,
200+ 'name' : cleaned_line ,
201+ 'selected' : self .has_selection_markers (line )
202+ }
203+ controls .append (continuation_control )
204+ if self .debug :
205+ print (f" Found continuation text: '{ cleaned_line } '" )
206+ found_first_content_after_header = True
207+ continue
208+
209+ if control_id :
210+ found_first_content_after_header = True
211+
212+ # Save previous control if exists
213+ if current_control and current_control ['name' ]:
214+ controls .append (current_control )
215+ if self .debug :
216+ control_name = current_control ['name' ][:50 ] + '...' if len (current_control ['name' ]) > 50 else current_control ['name' ]
217+ print (f" Saved control: { current_control ['id' ]} - { control_name } (Selected: { current_control ['selected' ]} )" )
218+
219+ # Start new control
220+ current_control = {
221+ 'id' : control_id ,
222+ 'name' : '' ,
223+ 'selected' : False
224+ }
225+
226+ if self .debug :
227+ print (f" Processing control ID: { control_id } " )
228+
229+ # Check if there's title text on the same line after the ID
230+ remaining = line [len (control_id ):].strip ()
231+ if remaining and not self .is_only_markers (remaining ):
232+ current_control ['name' ] = self .clean_title (remaining )
233+
234+ # Check for selection markers on this line
235+ if self .has_selection_markers (line ):
236+ current_control ['selected' ] = True
237+
238+ elif current_control :
239+ found_first_content_after_header = True
240+
241+ # Check if this text should be added to the current control
242+ if self .should_continue_control_title (line , current_control ['id' ]):
243+ # Add to current control's name
244+ cleaned_line = self .clean_title (line )
245+ if cleaned_line : # Only add if there's meaningful content
246+ if current_control ['name' ]:
247+ current_control ['name' ] += ' ' + cleaned_line
248+ else :
249+ current_control ['name' ] = cleaned_line
250+
251+ # Check for selection markers on this line
252+ if self .has_selection_markers (line ):
253+ current_control ['selected' ] = True
254+
255+ else :
256+ # Look for selection markers even without a current control
257+ if controls and self .has_selection_markers (line ):
258+ controls [- 1 ]['selected' ] = True
259+
260+ # Save the last control if exists
261+ if current_control and current_control ['name' ]:
262+ controls .append (current_control )
263+ if self .debug :
264+ control_name = current_control ['name' ][:50 ] + '...' if len (current_control ['name' ]) > 50 else current_control ['name' ]
265+ print (f" Saved final control: { current_control ['id' ]} - { control_name } (Selected: { current_control ['selected' ]} )" )
266+
267+ print (f" Extracted { len (controls )} controls from page { page_num + 1 } " )
268+ return controls
269+
270+ def merge_continuation_controls (self , all_controls : List [Dict ]) -> List [Dict ]:
271+ """Merge controls that continue across pages."""
272+ merged_controls = []
273+
274+ for i , control in enumerate (all_controls ):
275+ if self .debug :
276+ print (f"Processing control for merge: { control .get ('id' , 'NO_ID' )} - { control .get ('name' , 'NO_NAME' )[:30 ]} ..." )
277+
278+ # Check if this is a continuation control
279+ if control .get ('id' ) == 'CONTINUATION' :
280+ if merged_controls :
281+ # Append to the last control's name
282+ last_control = merged_controls [- 1 ]
283+ old_name = last_control ['name' ]
284+ last_control ['name' ] += ' ' + control ['name' ]
285+ if self .debug :
286+ print (f" Merged continuation: '{ old_name } ' + '{ control ['name' ]} ' = '{ last_control ['name' ]} '" )
287+
288+ # Update selection status if needed
289+ if control .get ('selected' ):
290+ last_control ['selected' ] = True
291+ else :
292+ if self .debug :
293+ print (f" Warning: Found CONTINUATION control but no previous control to merge with" )
294+ else :
295+ # Regular control, just add it
296+ merged_controls .append (control )
297+
298+ return merged_controls
299+
300+ def parse_document (self ) -> List [Dict ]:
301+ """Parse the entire document and extract all controls."""
302+ if not self .doc :
303+ self .open_pdf ()
304+
305+ all_controls = []
306+
307+ # Process each page
308+ for page_num in range (len (self .doc )):
309+ print (f"Processing page { page_num + 1 } ..." )
310+ page_controls = self .extract_controls_from_page (page_num )
311+ all_controls .extend (page_controls )
312+
313+ # Merge controls that span across pages
314+ merged_controls = self .merge_continuation_controls (all_controls )
315+
316+ # Filter to only include selected controls and remove the selected field
317+ selected_controls = []
318+ for control in merged_controls :
319+ if (control .get ('id' ) and
320+ control ['id' ] != 'CONTINUATION' and
321+ control .get ('name' ) and
322+ control .get ('selected' , False )): # Only include if selected is True
323+ selected_controls .append ({
324+ 'id' : control ['id' ],
325+ 'name' : control ['name' ]
326+ # Note: 'selected' field is intentionally omitted
327+ })
328+
329+ self .controls = selected_controls
330+ return selected_controls
331+
332+ def save_to_json (self , output_path : str ):
333+ """Save the extracted controls to a JSON file."""
334+ try :
335+ with open (output_path , 'w' , encoding = 'utf-8' ) as f :
336+ json .dump (self .controls , f , indent = 2 , ensure_ascii = False )
337+ print (f"Successfully saved { len (self .controls )} controls to { output_path } " )
338+ except Exception as e :
339+ print (f"Error saving JSON file: { e } " )
340+
341+ def print_summary (self ):
342+ """Print a summary of extracted controls."""
343+ total = len (self .controls )
344+
345+ print (f"\n === EXTRACTION SUMMARY ===" )
346+ print (f"Selected controls extracted: { total } " )
347+
348+ if total > 0 :
349+ print (f"\n First 5 selected controls:" )
350+ for i , control in enumerate (self .controls [:5 ]):
351+ name_display = control ['name' ][:60 ] + '...' if len (control ['name' ]) > 60 else control ['name' ]
352+ print (f" ✓ { control ['id' ]} : { name_display } " )
353+
354+
355+ def main ():
356+ if len (sys .argv ) < 3 :
357+ print ("Usage: python cnssi_parser.py <input_pdf_path> <output_json_path> [--debug]" )
358+ print ("Example: python cnssi_parser.py cnssi_1253_selection.pdf controls.json" )
359+ print (" python cnssi_parser.py cnssi_1253_selection.pdf controls.json --debug" )
360+ sys .exit (1 )
361+
362+ pdf_path = sys .argv [1 ]
363+ json_path = sys .argv [2 ]
364+ debug = '--debug' in sys .argv
365+
366+ print ("CNSSI 1253 PDF Parser" )
367+ print ("=" * 50 )
368+
369+ parser = CNSSIParser (pdf_path , debug = debug )
370+
371+ try :
372+ controls = parser .parse_document ()
373+ parser .save_to_json (json_path )
374+ parser .print_summary ()
375+
376+ except Exception as e :
377+ print (f"Error during parsing: { e } " )
378+ sys .exit (1 )
379+
380+ finally :
381+ parser .close_pdf ()
382+
383+ print (f"\n ✓ Parsing completed successfully!" )
384+ print (f" Input: { pdf_path } " )
385+ print (f" Output: { json_path } " )
386+
387+
388+ if __name__ == "__main__" :
389+ main ()
0 commit comments