security-controls-mcp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,238 @@
1
+ """Provider abstraction for security standards."""
2
+
3
+ import json
4
+ from abc import ABC, abstractmethod
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional
7
+
8
+
9
+ class StandardMetadata:
10
+ """Metadata about a security standard."""
11
+
12
+ def __init__(self, data: Dict[str, Any]):
13
+ """Initialize metadata from dictionary."""
14
+ self.standard_id = data.get("standard_id", "")
15
+ self.title = data.get("title", "")
16
+ self.version = data.get("version", "")
17
+ self.purchased_from = data.get("purchased_from", "")
18
+ self.purchase_date = data.get("purchase_date", "")
19
+ self.imported_date = data.get("imported_date", "")
20
+ self.license = data.get("license", "")
21
+ self.pages = data.get("pages", 0)
22
+ self.restrictions = data.get("restrictions", [])
23
+
24
+
25
+ class SearchResult:
26
+ """A search result from a standard."""
27
+
28
+ def __init__(
29
+ self,
30
+ standard_id: str,
31
+ clause_id: str,
32
+ title: str,
33
+ content: str,
34
+ page: Optional[int] = None,
35
+ section_type: Optional[str] = None,
36
+ ):
37
+ """Initialize search result."""
38
+ self.standard_id = standard_id
39
+ self.clause_id = clause_id
40
+ self.title = title
41
+ self.content = content
42
+ self.page = page
43
+ self.section_type = section_type
44
+
45
+
46
+ class StandardProvider(ABC):
47
+ """Abstract base class for standard providers."""
48
+
49
+ @abstractmethod
50
+ def get_metadata(self) -> StandardMetadata:
51
+ """Get metadata about this standard."""
52
+ pass
53
+
54
+ @abstractmethod
55
+ def search(self, query: str, limit: int = 10) -> List[SearchResult]:
56
+ """Search for content within the standard.
57
+
58
+ Args:
59
+ query: Search query string
60
+ limit: Maximum number of results to return
61
+
62
+ Returns:
63
+ List of search results
64
+ """
65
+ pass
66
+
67
+ @abstractmethod
68
+ def get_clause(self, clause_id: str) -> Optional[SearchResult]:
69
+ """Get a specific clause by ID.
70
+
71
+ Args:
72
+ clause_id: The clause/section identifier (e.g., "5.1.2", "A.5.15")
73
+
74
+ Returns:
75
+ The clause content, or None if not found
76
+ """
77
+ pass
78
+
79
+ @abstractmethod
80
+ def get_all_clauses(self) -> List[SearchResult]:
81
+ """Get all clauses in the standard.
82
+
83
+ Returns:
84
+ List of all clauses
85
+ """
86
+ pass
87
+
88
+
89
+ class PaidStandardProvider(StandardProvider):
90
+ """Provider for paid standards loaded from JSON files."""
91
+
92
+ def __init__(self, standard_path: Path):
93
+ """Initialize provider from standard data directory.
94
+
95
+ Args:
96
+ standard_path: Path to standard data directory containing
97
+ metadata.json and full_text.json
98
+ """
99
+ self.standard_path = standard_path
100
+ self.metadata_file = standard_path / "metadata.json"
101
+ self.full_text_file = standard_path / "full_text.json"
102
+
103
+ # Load data
104
+ self._load_data()
105
+
106
+ def _load_data(self) -> None:
107
+ """Load metadata and full text data."""
108
+ if not self.metadata_file.exists():
109
+ raise FileNotFoundError(f"Metadata file not found: {self.metadata_file}")
110
+
111
+ if not self.full_text_file.exists():
112
+ raise FileNotFoundError(f"Full text file not found: {self.full_text_file}")
113
+
114
+ with open(self.metadata_file, "r") as f:
115
+ self.metadata = StandardMetadata(json.load(f))
116
+
117
+ with open(self.full_text_file, "r") as f:
118
+ self.data = json.load(f)
119
+
120
+ def get_metadata(self) -> StandardMetadata:
121
+ """Get metadata about this standard."""
122
+ return self.metadata
123
+
124
+ def search(self, query: str, limit: int = 10) -> List[SearchResult]:
125
+ """Search for content within the standard."""
126
+ query_lower = query.lower()
127
+ results = []
128
+
129
+ # Search in sections
130
+ for section in self._iterate_sections(self.data.get("structure", {}).get("sections", [])):
131
+ if (
132
+ query_lower in section.get("content", "").lower()
133
+ or query_lower in section.get("title", "").lower()
134
+ ):
135
+ results.append(
136
+ SearchResult(
137
+ standard_id=self.metadata.standard_id,
138
+ clause_id=section["id"],
139
+ title=section["title"],
140
+ content=section.get("content", "")[:500], # Truncate for preview
141
+ page=section.get("page"),
142
+ section_type="section",
143
+ )
144
+ )
145
+ if len(results) >= limit:
146
+ return results
147
+
148
+ # Search in annexes
149
+ for annex in self.data.get("structure", {}).get("annexes", []):
150
+ for control in annex.get("controls", []):
151
+ if (
152
+ query_lower in control.get("content", "").lower()
153
+ or query_lower in control.get("title", "").lower()
154
+ ):
155
+ results.append(
156
+ SearchResult(
157
+ standard_id=self.metadata.standard_id,
158
+ clause_id=control["id"],
159
+ title=control["title"],
160
+ content=control.get("content", "")[:500],
161
+ page=control.get("page"),
162
+ section_type=f"Annex {annex['id']} - {control.get('category', 'control')}",
163
+ )
164
+ )
165
+ if len(results) >= limit:
166
+ return results
167
+
168
+ return results
169
+
170
+ def _iterate_sections(self, sections: List[Dict]) -> List[Dict]:
171
+ """Recursively iterate through sections and subsections."""
172
+ for section in sections:
173
+ yield section
174
+ # Recursively iterate subsections
175
+ if "subsections" in section:
176
+ yield from self._iterate_sections(section["subsections"])
177
+
178
+ def get_clause(self, clause_id: str) -> Optional[SearchResult]:
179
+ """Get a specific clause by ID."""
180
+ # Search in sections
181
+ for section in self._iterate_sections(self.data.get("structure", {}).get("sections", [])):
182
+ if section["id"] == clause_id:
183
+ return SearchResult(
184
+ standard_id=self.metadata.standard_id,
185
+ clause_id=section["id"],
186
+ title=section["title"],
187
+ content=section.get("content", ""),
188
+ page=section.get("page"),
189
+ section_type="section",
190
+ )
191
+
192
+ # Search in annexes
193
+ for annex in self.data.get("structure", {}).get("annexes", []):
194
+ for control in annex.get("controls", []):
195
+ if control["id"] == clause_id:
196
+ return SearchResult(
197
+ standard_id=self.metadata.standard_id,
198
+ clause_id=control["id"],
199
+ title=control["title"],
200
+ content=control.get("content", ""),
201
+ page=control.get("page"),
202
+ section_type=f"Annex {annex['id']} - {control.get('category', 'control')}",
203
+ )
204
+
205
+ return None
206
+
207
+ def get_all_clauses(self) -> List[SearchResult]:
208
+ """Get all clauses in the standard."""
209
+ results = []
210
+
211
+ # Get all sections
212
+ for section in self._iterate_sections(self.data.get("structure", {}).get("sections", [])):
213
+ results.append(
214
+ SearchResult(
215
+ standard_id=self.metadata.standard_id,
216
+ clause_id=section["id"],
217
+ title=section["title"],
218
+ content=section.get("content", "")[:200], # Brief preview
219
+ page=section.get("page"),
220
+ section_type="section",
221
+ )
222
+ )
223
+
224
+ # Get all annex controls
225
+ for annex in self.data.get("structure", {}).get("annexes", []):
226
+ for control in annex.get("controls", []):
227
+ results.append(
228
+ SearchResult(
229
+ standard_id=self.metadata.standard_id,
230
+ clause_id=control["id"],
231
+ title=control["title"],
232
+ content=control.get("content", "")[:200],
233
+ page=control.get("page"),
234
+ section_type=f"Annex {annex['id']}",
235
+ )
236
+ )
237
+
238
+ return results
@@ -0,0 +1,132 @@
1
+ """Registry for managing all standard providers."""
2
+
3
+ from typing import Dict, List, Optional
4
+
5
+ from .config import Config
6
+ from .providers import PaidStandardProvider, SearchResult, StandardProvider
7
+
8
+
9
+ class StandardRegistry:
10
+ """Registry for all available standards (SCF + paid)."""
11
+
12
+ def __init__(self, config: Optional[Config] = None):
13
+ """Initialize the registry.
14
+
15
+ Args:
16
+ config: Configuration instance. If None, creates default config.
17
+ """
18
+ self.config = config or Config()
19
+ self.providers: Dict[str, StandardProvider] = {}
20
+
21
+ # Load all enabled paid standards
22
+ self._load_paid_standards()
23
+
24
+ def _load_paid_standards(self) -> None:
25
+ """Load all enabled paid standards from config."""
26
+ enabled_standards = self.config.get_enabled_standards()
27
+
28
+ for standard_id, standard_config in enabled_standards.items():
29
+ try:
30
+ standard_path = self.config.get_standard_path(standard_id)
31
+ if standard_path and standard_path.exists():
32
+ provider = PaidStandardProvider(standard_path)
33
+ self.providers[standard_id] = provider
34
+ except Exception as e:
35
+ # Log error but don't fail - just skip this standard
36
+ print(f"Warning: Could not load standard '{standard_id}': {e}")
37
+
38
+ def get_provider(self, standard_id: str) -> Optional[StandardProvider]:
39
+ """Get a provider by standard ID.
40
+
41
+ Args:
42
+ standard_id: The standard identifier
43
+
44
+ Returns:
45
+ The provider, or None if not found
46
+ """
47
+ return self.providers.get(standard_id)
48
+
49
+ def list_standards(self) -> List[Dict[str, str]]:
50
+ """List all available standards.
51
+
52
+ Returns:
53
+ List of dictionaries with standard information
54
+ """
55
+ standards = []
56
+
57
+ # Add SCF (always available)
58
+ standards.append(
59
+ {
60
+ "standard_id": "scf",
61
+ "title": "Secure Controls Framework (SCF) 2025.4",
62
+ "type": "built-in",
63
+ "license": "Creative Commons BY-ND 4.0",
64
+ "controls": "1,451 controls across 16 frameworks",
65
+ }
66
+ )
67
+
68
+ # Add paid standards
69
+ for standard_id, provider in self.providers.items():
70
+ metadata = provider.get_metadata()
71
+ standards.append(
72
+ {
73
+ "standard_id": standard_id,
74
+ "title": metadata.title,
75
+ "type": "paid",
76
+ "license": metadata.license,
77
+ "version": metadata.version,
78
+ "purchased_from": metadata.purchased_from,
79
+ "purchase_date": metadata.purchase_date,
80
+ }
81
+ )
82
+
83
+ return standards
84
+
85
+ def search_all(self, query: str, limit: int = 20) -> Dict[str, List[SearchResult]]:
86
+ """Search across all available paid standards.
87
+
88
+ Args:
89
+ query: Search query string
90
+ limit: Maximum total results
91
+
92
+ Returns:
93
+ Dictionary mapping standard_id to list of search results
94
+ """
95
+ all_results = {}
96
+ results_per_standard = max(5, limit // max(1, len(self.providers)))
97
+
98
+ for standard_id, provider in self.providers.items():
99
+ results = provider.search(query, limit=results_per_standard)
100
+ if results:
101
+ all_results[standard_id] = results
102
+
103
+ return all_results
104
+
105
+ def get_clause_from_any_standard(self, clause_id: str) -> Optional[tuple[str, SearchResult]]:
106
+ """Search for a clause across all standards.
107
+
108
+ Args:
109
+ clause_id: The clause identifier to search for
110
+
111
+ Returns:
112
+ Tuple of (standard_id, SearchResult) if found, None otherwise
113
+ """
114
+ for standard_id, provider in self.providers.items():
115
+ result = provider.get_clause(clause_id)
116
+ if result:
117
+ return (standard_id, result)
118
+
119
+ return None
120
+
121
+ def has_paid_standards(self) -> bool:
122
+ """Check if any paid standards are loaded.
123
+
124
+ Returns:
125
+ True if at least one paid standard is available
126
+ """
127
+ return len(self.providers) > 0
128
+
129
+ def reload(self) -> None:
130
+ """Reload all standards from config."""
131
+ self.providers.clear()
132
+ self._load_paid_standards()