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.
- security_controls_mcp/__init__.py +3 -0
- security_controls_mcp/__main__.py +8 -0
- security_controls_mcp/cli.py +255 -0
- security_controls_mcp/config.py +145 -0
- security_controls_mcp/data/framework-to-scf.json +13986 -0
- security_controls_mcp/data/scf-controls.json +50162 -0
- security_controls_mcp/data_loader.py +180 -0
- security_controls_mcp/extractors/__init__.py +5 -0
- security_controls_mcp/extractors/pdf_extractor.py +248 -0
- security_controls_mcp/http_server.py +477 -0
- security_controls_mcp/legal_notice.py +82 -0
- security_controls_mcp/providers.py +238 -0
- security_controls_mcp/registry.py +132 -0
- security_controls_mcp/server.py +613 -0
- security_controls_mcp-0.2.0.dist-info/METADATA +467 -0
- security_controls_mcp-0.2.0.dist-info/RECORD +21 -0
- security_controls_mcp-0.2.0.dist-info/WHEEL +5 -0
- security_controls_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- security_controls_mcp-0.2.0.dist-info/licenses/LICENSE +17 -0
- security_controls_mcp-0.2.0.dist-info/licenses/LICENSE-DATA.md +61 -0
- security_controls_mcp-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|