Skip to content

Commit e70a2d5

Browse files
author
Peter Wagstaff
committed
Updating to use sl5 controls
1 parent e00e1b3 commit e70a2d5

28 files changed

Lines changed: 9213 additions & 7540 deletions

classified-information-extraction/classified_information_overlay.pdf renamed to classified_information/classified_information_overlay.pdf

File renamed without changes.

classified-information-extraction/classified_information_overlay_extractor.py renamed to classified_information/classified_information_overlay_extractor.py

File renamed without changes.

extracted_classified_information_overlay.json renamed to classified_information/extracted_classified_information_overlay.json

File renamed without changes.

cnssi_1253/cnssi_1253_overlay.pdf

625 KB
Binary file not shown.

cnssi_1253/cnssi_1253_overlay_extractor.py

Lines changed: 403 additions & 0 deletions
Large diffs are not rendered by default.
3.84 MB
Binary file not shown.
Lines changed: 389 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,389 @@
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"\nFirst 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

Comments
 (0)