ospac 0.1.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.
Potentially problematic release.
This version of ospac might be problematic. Click here for more details.
- ospac/__init__.py +19 -0
- ospac/cli/__init__.py +5 -0
- ospac/cli/commands.py +554 -0
- ospac/core/compatibility_matrix.py +332 -0
- ospac/models/__init__.py +12 -0
- ospac/models/compliance.py +161 -0
- ospac/models/license.py +82 -0
- ospac/models/policy.py +97 -0
- ospac/pipeline/__init__.py +14 -0
- ospac/pipeline/data_generator.py +530 -0
- ospac/pipeline/llm_analyzer.py +338 -0
- ospac/pipeline/llm_providers.py +463 -0
- ospac/pipeline/spdx_processor.py +283 -0
- ospac/runtime/__init__.py +11 -0
- ospac/runtime/engine.py +127 -0
- ospac/runtime/evaluator.py +72 -0
- ospac/runtime/loader.py +54 -0
- ospac/utils/__init__.py +3 -0
- ospac-0.1.0.dist-info/METADATA +269 -0
- ospac-0.1.0.dist-info/RECORD +25 -0
- ospac-0.1.0.dist-info/WHEEL +5 -0
- ospac-0.1.0.dist-info/entry_points.txt +2 -0
- ospac-0.1.0.dist-info/licenses/AUTHORS.md +9 -0
- ospac-0.1.0.dist-info/licenses/LICENSE +201 -0
- ospac-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Efficient compatibility matrix storage and retrieval."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List, Optional, Tuple, Set
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CompatibilityStatus(Enum):
|
|
11
|
+
"""License compatibility status."""
|
|
12
|
+
COMPATIBLE = "compatible"
|
|
13
|
+
INCOMPATIBLE = "incompatible"
|
|
14
|
+
REVIEW_NEEDED = "review_needed"
|
|
15
|
+
UNKNOWN = "unknown"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CompatibilityMatrix:
|
|
19
|
+
"""
|
|
20
|
+
Efficient storage for license compatibility relationships.
|
|
21
|
+
|
|
22
|
+
Uses sparse matrix representation to reduce storage size.
|
|
23
|
+
Only stores non-default relationships (default is "unknown").
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, data_dir: str = "data/compatibility"):
|
|
27
|
+
self.data_dir = Path(data_dir)
|
|
28
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
# In-memory caches
|
|
31
|
+
self._compatibility_cache: Dict[str, Dict[str, str]] = {}
|
|
32
|
+
self._category_cache: Dict[str, List[str]] = {}
|
|
33
|
+
self._metadata: Dict = {}
|
|
34
|
+
|
|
35
|
+
# File paths
|
|
36
|
+
self.metadata_file = self.data_dir / "metadata.json"
|
|
37
|
+
self.categories_file = self.data_dir / "categories.json"
|
|
38
|
+
self.relationships_dir = self.data_dir / "relationships"
|
|
39
|
+
self.relationships_dir.mkdir(exist_ok=True)
|
|
40
|
+
|
|
41
|
+
def build_from_full_matrix(self, full_matrix_path: str) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Convert full NxN matrix to efficient sparse format.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
full_matrix_path: Path to full compatibility matrix JSON
|
|
47
|
+
"""
|
|
48
|
+
print(f"Loading full matrix from {full_matrix_path}...")
|
|
49
|
+
with open(full_matrix_path, 'r') as f:
|
|
50
|
+
data = json.load(f)
|
|
51
|
+
|
|
52
|
+
compatibility = data.get("compatibility", {})
|
|
53
|
+
|
|
54
|
+
# Extract metadata
|
|
55
|
+
self._metadata = {
|
|
56
|
+
"version": data.get("version", "1.0"),
|
|
57
|
+
"generated": data.get("generated"),
|
|
58
|
+
"total_licenses": len(compatibility),
|
|
59
|
+
"format": "sparse",
|
|
60
|
+
"default_status": "unknown"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Categorize licenses based on patterns
|
|
64
|
+
categories = self._categorize_licenses(list(compatibility.keys()))
|
|
65
|
+
|
|
66
|
+
# Save metadata and categories
|
|
67
|
+
self._save_metadata()
|
|
68
|
+
self._save_categories(categories)
|
|
69
|
+
|
|
70
|
+
# Process and save relationships in chunks
|
|
71
|
+
self._process_relationships(compatibility, categories)
|
|
72
|
+
|
|
73
|
+
def _categorize_licenses(self, license_ids: List[str]) -> Dict[str, List[str]]:
|
|
74
|
+
"""Categorize licenses by family/type for efficient storage."""
|
|
75
|
+
categories = {
|
|
76
|
+
"gpl": [],
|
|
77
|
+
"lgpl": [],
|
|
78
|
+
"agpl": [],
|
|
79
|
+
"bsd": [],
|
|
80
|
+
"mit": [],
|
|
81
|
+
"apache": [],
|
|
82
|
+
"cc": [],
|
|
83
|
+
"public_domain": [],
|
|
84
|
+
"proprietary": [],
|
|
85
|
+
"other": []
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for license_id in license_ids:
|
|
89
|
+
lid_lower = license_id.lower()
|
|
90
|
+
|
|
91
|
+
if "gpl-" in lid_lower and "lgpl" not in lid_lower and "agpl" not in lid_lower:
|
|
92
|
+
categories["gpl"].append(license_id)
|
|
93
|
+
elif "lgpl" in lid_lower:
|
|
94
|
+
categories["lgpl"].append(license_id)
|
|
95
|
+
elif "agpl" in lid_lower:
|
|
96
|
+
categories["agpl"].append(license_id)
|
|
97
|
+
elif "bsd" in lid_lower:
|
|
98
|
+
categories["bsd"].append(license_id)
|
|
99
|
+
elif "mit" in lid_lower:
|
|
100
|
+
categories["mit"].append(license_id)
|
|
101
|
+
elif "apache" in lid_lower:
|
|
102
|
+
categories["apache"].append(license_id)
|
|
103
|
+
elif lid_lower.startswith("cc"):
|
|
104
|
+
categories["cc"].append(license_id)
|
|
105
|
+
elif "public" in lid_lower or "unlicense" in lid_lower or lid_lower == "0bsd":
|
|
106
|
+
categories["public_domain"].append(license_id)
|
|
107
|
+
elif any(prop in lid_lower for prop in ["proprietary", "commercial", "elastic"]):
|
|
108
|
+
categories["proprietary"].append(license_id)
|
|
109
|
+
else:
|
|
110
|
+
categories["other"].append(license_id)
|
|
111
|
+
|
|
112
|
+
# Remove empty categories
|
|
113
|
+
return {k: v for k, v in categories.items() if v}
|
|
114
|
+
|
|
115
|
+
def _process_relationships(self, compatibility: Dict, categories: Dict[str, List[str]]) -> None:
|
|
116
|
+
"""Process and store non-default relationships efficiently."""
|
|
117
|
+
print("Processing compatibility relationships...")
|
|
118
|
+
|
|
119
|
+
# Statistics
|
|
120
|
+
total_relationships = 0
|
|
121
|
+
stored_relationships = 0
|
|
122
|
+
|
|
123
|
+
# Process by category to create smaller files
|
|
124
|
+
for category, licenses in categories.items():
|
|
125
|
+
category_relationships = {}
|
|
126
|
+
|
|
127
|
+
for license_id in licenses:
|
|
128
|
+
if license_id not in compatibility:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
license_compat = compatibility[license_id]
|
|
132
|
+
|
|
133
|
+
# Only store non-default relationships
|
|
134
|
+
non_default = {
|
|
135
|
+
target: status
|
|
136
|
+
for target, status in license_compat.items()
|
|
137
|
+
if status and status != "unknown"
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if non_default:
|
|
141
|
+
category_relationships[license_id] = non_default
|
|
142
|
+
stored_relationships += len(non_default)
|
|
143
|
+
|
|
144
|
+
total_relationships += len(license_compat)
|
|
145
|
+
|
|
146
|
+
# Save category relationships
|
|
147
|
+
if category_relationships:
|
|
148
|
+
category_file = self.relationships_dir / f"{category}.json"
|
|
149
|
+
with open(category_file, 'w') as f:
|
|
150
|
+
json.dump(category_relationships, f, indent=2)
|
|
151
|
+
print(f" Saved {category}: {len(category_relationships)} licenses")
|
|
152
|
+
|
|
153
|
+
compression_ratio = (1 - stored_relationships / total_relationships) * 100 if total_relationships > 0 else 0
|
|
154
|
+
print(f"\nCompression: {stored_relationships}/{total_relationships} relationships stored")
|
|
155
|
+
print(f"Space saved: {compression_ratio:.1f}%")
|
|
156
|
+
|
|
157
|
+
def _save_metadata(self) -> None:
|
|
158
|
+
"""Save metadata to file."""
|
|
159
|
+
with open(self.metadata_file, 'w') as f:
|
|
160
|
+
json.dump(self._metadata, f, indent=2)
|
|
161
|
+
|
|
162
|
+
def _save_categories(self, categories: Dict[str, List[str]]) -> None:
|
|
163
|
+
"""Save license categories to file."""
|
|
164
|
+
with open(self.categories_file, 'w') as f:
|
|
165
|
+
json.dump(categories, f, indent=2)
|
|
166
|
+
|
|
167
|
+
def load(self) -> None:
|
|
168
|
+
"""Load sparse matrix data into memory."""
|
|
169
|
+
# Load metadata
|
|
170
|
+
if self.metadata_file.exists():
|
|
171
|
+
with open(self.metadata_file, 'r') as f:
|
|
172
|
+
self._metadata = json.load(f)
|
|
173
|
+
|
|
174
|
+
# Load categories
|
|
175
|
+
if self.categories_file.exists():
|
|
176
|
+
with open(self.categories_file, 'r') as f:
|
|
177
|
+
self._category_cache = json.load(f)
|
|
178
|
+
|
|
179
|
+
# Load relationships on demand (lazy loading)
|
|
180
|
+
self._compatibility_cache = {}
|
|
181
|
+
|
|
182
|
+
def get_compatibility(self, license1: str, license2: str) -> str:
|
|
183
|
+
"""
|
|
184
|
+
Get compatibility status between two licenses.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
license1: First license ID
|
|
188
|
+
license2: Second license ID
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Compatibility status
|
|
192
|
+
"""
|
|
193
|
+
# Check cache first
|
|
194
|
+
if license1 in self._compatibility_cache:
|
|
195
|
+
if license2 in self._compatibility_cache[license1]:
|
|
196
|
+
return self._compatibility_cache[license1][license2]
|
|
197
|
+
|
|
198
|
+
# Load from file if needed
|
|
199
|
+
status = self._load_relationship(license1, license2)
|
|
200
|
+
|
|
201
|
+
# Cache the result
|
|
202
|
+
if license1 not in self._compatibility_cache:
|
|
203
|
+
self._compatibility_cache[license1] = {}
|
|
204
|
+
self._compatibility_cache[license1][license2] = status
|
|
205
|
+
|
|
206
|
+
return status
|
|
207
|
+
|
|
208
|
+
def _load_relationship(self, license1: str, license2: str) -> str:
|
|
209
|
+
"""Load specific relationship from file."""
|
|
210
|
+
# Find category for license1
|
|
211
|
+
category = self._find_category(license1)
|
|
212
|
+
if not category:
|
|
213
|
+
return self._metadata.get("default_status", "unknown")
|
|
214
|
+
|
|
215
|
+
# Load category file if not cached
|
|
216
|
+
category_file = self.relationships_dir / f"{category}.json"
|
|
217
|
+
if not category_file.exists():
|
|
218
|
+
return self._metadata.get("default_status", "unknown")
|
|
219
|
+
|
|
220
|
+
with open(category_file, 'r') as f:
|
|
221
|
+
relationships = json.load(f)
|
|
222
|
+
|
|
223
|
+
# Get relationship
|
|
224
|
+
if license1 in relationships:
|
|
225
|
+
rel = relationships[license1].get(license2)
|
|
226
|
+
# Handle dict format (with static/dynamic/distribution keys)
|
|
227
|
+
if isinstance(rel, dict):
|
|
228
|
+
# Return overall compatibility based on static linking (most restrictive)
|
|
229
|
+
static = rel.get("static_linking", "unknown")
|
|
230
|
+
if static == "compatible":
|
|
231
|
+
return "compatible"
|
|
232
|
+
elif static == "incompatible":
|
|
233
|
+
return "incompatible"
|
|
234
|
+
elif static == "review_required":
|
|
235
|
+
return "review_needed"
|
|
236
|
+
return static
|
|
237
|
+
# Handle string format
|
|
238
|
+
elif isinstance(rel, str):
|
|
239
|
+
return rel
|
|
240
|
+
else:
|
|
241
|
+
return self._metadata.get("default_status", "unknown")
|
|
242
|
+
|
|
243
|
+
return self._metadata.get("default_status", "unknown")
|
|
244
|
+
|
|
245
|
+
def _find_category(self, license_id: str) -> Optional[str]:
|
|
246
|
+
"""Find which category a license belongs to."""
|
|
247
|
+
for category, licenses in self._category_cache.items():
|
|
248
|
+
if license_id in licenses:
|
|
249
|
+
return category
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
def get_compatible_licenses(self, license_id: str) -> List[str]:
|
|
253
|
+
"""Get all licenses compatible with the given license."""
|
|
254
|
+
compatible = []
|
|
255
|
+
|
|
256
|
+
# Load all relationships for this license
|
|
257
|
+
category = self._find_category(license_id)
|
|
258
|
+
if category:
|
|
259
|
+
category_file = self.relationships_dir / f"{category}.json"
|
|
260
|
+
if category_file.exists():
|
|
261
|
+
with open(category_file, 'r') as f:
|
|
262
|
+
relationships = json.load(f)
|
|
263
|
+
|
|
264
|
+
if license_id in relationships:
|
|
265
|
+
for target, status in relationships[license_id].items():
|
|
266
|
+
# Handle dict format
|
|
267
|
+
if isinstance(status, dict):
|
|
268
|
+
if status.get("static_linking") == "compatible":
|
|
269
|
+
compatible.append(target)
|
|
270
|
+
# Handle string format
|
|
271
|
+
elif status == CompatibilityStatus.COMPATIBLE.value:
|
|
272
|
+
compatible.append(target)
|
|
273
|
+
|
|
274
|
+
return sorted(compatible)
|
|
275
|
+
|
|
276
|
+
def get_incompatible_licenses(self, license_id: str) -> List[str]:
|
|
277
|
+
"""Get all licenses incompatible with the given license."""
|
|
278
|
+
incompatible = []
|
|
279
|
+
|
|
280
|
+
# Load all relationships for this license
|
|
281
|
+
category = self._find_category(license_id)
|
|
282
|
+
if category:
|
|
283
|
+
category_file = self.relationships_dir / f"{category}.json"
|
|
284
|
+
if category_file.exists():
|
|
285
|
+
with open(category_file, 'r') as f:
|
|
286
|
+
relationships = json.load(f)
|
|
287
|
+
|
|
288
|
+
if license_id in relationships:
|
|
289
|
+
for target, status in relationships[license_id].items():
|
|
290
|
+
if status == CompatibilityStatus.INCOMPATIBLE.value:
|
|
291
|
+
incompatible.append(target)
|
|
292
|
+
|
|
293
|
+
return sorted(incompatible)
|
|
294
|
+
|
|
295
|
+
def export_full_matrix(self, output_path: str) -> None:
|
|
296
|
+
"""Export back to full matrix format if needed."""
|
|
297
|
+
print("Exporting to full matrix format...")
|
|
298
|
+
|
|
299
|
+
# Load all relationships
|
|
300
|
+
full_compatibility = {}
|
|
301
|
+
|
|
302
|
+
for category_file in self.relationships_dir.glob("*.json"):
|
|
303
|
+
with open(category_file, 'r') as f:
|
|
304
|
+
relationships = json.load(f)
|
|
305
|
+
full_compatibility.update(relationships)
|
|
306
|
+
|
|
307
|
+
# Fill in defaults for all license pairs
|
|
308
|
+
all_licenses = []
|
|
309
|
+
for licenses in self._category_cache.values():
|
|
310
|
+
all_licenses.extend(licenses)
|
|
311
|
+
|
|
312
|
+
complete_matrix = {}
|
|
313
|
+
for license1 in all_licenses:
|
|
314
|
+
complete_matrix[license1] = {}
|
|
315
|
+
for license2 in all_licenses:
|
|
316
|
+
if license1 in full_compatibility and license2 in full_compatibility[license1]:
|
|
317
|
+
complete_matrix[license1][license2] = full_compatibility[license1][license2]
|
|
318
|
+
else:
|
|
319
|
+
complete_matrix[license1][license2] = self._metadata.get("default_status", "unknown")
|
|
320
|
+
|
|
321
|
+
# Save full matrix
|
|
322
|
+
output_data = {
|
|
323
|
+
"version": self._metadata.get("version", "1.0"),
|
|
324
|
+
"generated": self._metadata.get("generated"),
|
|
325
|
+
"total_licenses": len(all_licenses),
|
|
326
|
+
"compatibility": complete_matrix
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
with open(output_path, 'w') as f:
|
|
330
|
+
json.dump(output_data, f, indent=2)
|
|
331
|
+
|
|
332
|
+
print(f"Exported full matrix to {output_path}")
|
ospac/models/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Data models for OSPAC."""
|
|
2
|
+
|
|
3
|
+
from ospac.models.license import License
|
|
4
|
+
from ospac.models.policy import Policy
|
|
5
|
+
from ospac.models.compliance import ComplianceResult, PolicyResult
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"License",
|
|
9
|
+
"Policy",
|
|
10
|
+
"ComplianceResult",
|
|
11
|
+
"PolicyResult",
|
|
12
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Compliance result models.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Dict, List, Any, Optional
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ComplianceStatus(Enum):
|
|
11
|
+
"""Status of compliance check."""
|
|
12
|
+
|
|
13
|
+
COMPLIANT = "compliant"
|
|
14
|
+
NON_COMPLIANT = "non_compliant"
|
|
15
|
+
WARNING = "warning"
|
|
16
|
+
REQUIRES_REVIEW = "requires_review"
|
|
17
|
+
UNKNOWN = "unknown"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ActionType(Enum):
|
|
21
|
+
"""Action to take based on policy evaluation."""
|
|
22
|
+
|
|
23
|
+
ALLOW = "allow"
|
|
24
|
+
DENY = "deny"
|
|
25
|
+
FLAG_FOR_REVIEW = "flag_for_review"
|
|
26
|
+
CONTAMINATE = "contaminate"
|
|
27
|
+
APPROVE = "approve"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class PolicyResult:
|
|
32
|
+
"""Result of policy evaluation."""
|
|
33
|
+
|
|
34
|
+
rule_id: str
|
|
35
|
+
action: ActionType
|
|
36
|
+
severity: str = "info"
|
|
37
|
+
message: Optional[str] = None
|
|
38
|
+
requirements: List[str] = field(default_factory=list)
|
|
39
|
+
remediation: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def aggregate(cls, results: List["PolicyResult"]) -> "PolicyResult":
|
|
43
|
+
"""Aggregate multiple results into a single result."""
|
|
44
|
+
if not results:
|
|
45
|
+
return cls(
|
|
46
|
+
rule_id="aggregate",
|
|
47
|
+
action=ActionType.ALLOW,
|
|
48
|
+
severity="info",
|
|
49
|
+
message="No policies matched",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Find the highest severity result
|
|
53
|
+
severity_order = {"error": 3, "warning": 2, "info": 1}
|
|
54
|
+
highest_severity = max(results, key=lambda r: severity_order.get(r.severity, 0))
|
|
55
|
+
|
|
56
|
+
# Determine overall action (most restrictive wins)
|
|
57
|
+
action_priority = {
|
|
58
|
+
ActionType.DENY: 5,
|
|
59
|
+
ActionType.CONTAMINATE: 4,
|
|
60
|
+
ActionType.FLAG_FOR_REVIEW: 3,
|
|
61
|
+
ActionType.ALLOW: 2,
|
|
62
|
+
ActionType.APPROVE: 1,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
most_restrictive = max(results, key=lambda r: action_priority.get(r.action, 0))
|
|
66
|
+
|
|
67
|
+
# Combine all requirements
|
|
68
|
+
all_requirements = []
|
|
69
|
+
for result in results:
|
|
70
|
+
all_requirements.extend(result.requirements)
|
|
71
|
+
|
|
72
|
+
return cls(
|
|
73
|
+
rule_id="aggregate",
|
|
74
|
+
action=most_restrictive.action,
|
|
75
|
+
severity=highest_severity.severity,
|
|
76
|
+
message=f"Evaluated {len(results)} rules",
|
|
77
|
+
requirements=list(set(all_requirements)),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class ComplianceResult:
|
|
83
|
+
"""Final compliance result."""
|
|
84
|
+
|
|
85
|
+
status: ComplianceStatus
|
|
86
|
+
licenses_checked: List[str] = field(default_factory=list)
|
|
87
|
+
violations: List[Dict[str, Any]] = field(default_factory=list)
|
|
88
|
+
warnings: List[Dict[str, Any]] = field(default_factory=list)
|
|
89
|
+
obligations: List[str] = field(default_factory=list)
|
|
90
|
+
required_actions: List[str] = field(default_factory=list)
|
|
91
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def is_compliant(self) -> bool:
|
|
95
|
+
"""Check if the result is compliant."""
|
|
96
|
+
return self.status == ComplianceStatus.COMPLIANT
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def needs_review(self) -> bool:
|
|
100
|
+
"""Check if manual review is required."""
|
|
101
|
+
return self.status == ComplianceStatus.REQUIRES_REVIEW
|
|
102
|
+
|
|
103
|
+
def add_violation(self, rule_id: str, message: str, severity: str = "error") -> None:
|
|
104
|
+
"""Add a violation to the result."""
|
|
105
|
+
self.violations.append({
|
|
106
|
+
"rule_id": rule_id,
|
|
107
|
+
"message": message,
|
|
108
|
+
"severity": severity,
|
|
109
|
+
})
|
|
110
|
+
if self.status != ComplianceStatus.NON_COMPLIANT:
|
|
111
|
+
self.status = ComplianceStatus.NON_COMPLIANT
|
|
112
|
+
|
|
113
|
+
def add_warning(self, rule_id: str, message: str) -> None:
|
|
114
|
+
"""Add a warning to the result."""
|
|
115
|
+
self.warnings.append({
|
|
116
|
+
"rule_id": rule_id,
|
|
117
|
+
"message": message,
|
|
118
|
+
"severity": "warning",
|
|
119
|
+
})
|
|
120
|
+
if self.status == ComplianceStatus.COMPLIANT:
|
|
121
|
+
self.status = ComplianceStatus.WARNING
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def from_policy_result(cls, policy_result: PolicyResult) -> "ComplianceResult":
|
|
125
|
+
"""Create a ComplianceResult from a PolicyResult."""
|
|
126
|
+
result = cls(status=ComplianceStatus.UNKNOWN)
|
|
127
|
+
|
|
128
|
+
if policy_result.action == ActionType.DENY:
|
|
129
|
+
result.status = ComplianceStatus.NON_COMPLIANT
|
|
130
|
+
if policy_result.message:
|
|
131
|
+
result.add_violation(policy_result.rule_id, policy_result.message, policy_result.severity)
|
|
132
|
+
|
|
133
|
+
elif policy_result.action == ActionType.FLAG_FOR_REVIEW:
|
|
134
|
+
result.status = ComplianceStatus.REQUIRES_REVIEW
|
|
135
|
+
if policy_result.message:
|
|
136
|
+
result.add_warning(policy_result.rule_id, policy_result.message)
|
|
137
|
+
|
|
138
|
+
elif policy_result.action in [ActionType.ALLOW, ActionType.APPROVE]:
|
|
139
|
+
result.status = ComplianceStatus.COMPLIANT
|
|
140
|
+
|
|
141
|
+
if policy_result.requirements:
|
|
142
|
+
result.obligations.extend(policy_result.requirements)
|
|
143
|
+
|
|
144
|
+
if policy_result.remediation:
|
|
145
|
+
result.required_actions.append(policy_result.remediation)
|
|
146
|
+
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
150
|
+
"""Convert to dictionary for serialization."""
|
|
151
|
+
return {
|
|
152
|
+
"status": self.status.value,
|
|
153
|
+
"is_compliant": self.is_compliant,
|
|
154
|
+
"needs_review": self.needs_review,
|
|
155
|
+
"licenses_checked": self.licenses_checked,
|
|
156
|
+
"violations": self.violations,
|
|
157
|
+
"warnings": self.warnings,
|
|
158
|
+
"obligations": self.obligations,
|
|
159
|
+
"required_actions": self.required_actions,
|
|
160
|
+
"metadata": self.metadata,
|
|
161
|
+
}
|
ospac/models/license.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
License data model.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Dict, List, Optional, Any
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class License:
|
|
11
|
+
"""Represents a software license with its properties and requirements."""
|
|
12
|
+
|
|
13
|
+
id: str
|
|
14
|
+
name: str
|
|
15
|
+
type: str # permissive, copyleft_weak, copyleft_strong, proprietary
|
|
16
|
+
spdx_id: Optional[str] = None
|
|
17
|
+
|
|
18
|
+
properties: Dict[str, bool] = field(default_factory=dict)
|
|
19
|
+
requirements: Dict[str, bool] = field(default_factory=dict)
|
|
20
|
+
compatibility: Dict[str, Dict] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
def is_compatible_with(self, other: "License", context: str = "general") -> bool:
|
|
23
|
+
"""Check if this license is compatible with another."""
|
|
24
|
+
if context not in self.compatibility:
|
|
25
|
+
context = "general"
|
|
26
|
+
|
|
27
|
+
if context in self.compatibility:
|
|
28
|
+
compat_rules = self.compatibility[context]
|
|
29
|
+
|
|
30
|
+
# Check explicit compatible list
|
|
31
|
+
if "compatible_with" in compat_rules:
|
|
32
|
+
if other.id in compat_rules["compatible_with"]:
|
|
33
|
+
return True
|
|
34
|
+
if other.type in compat_rules["compatible_with"]:
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
# Check explicit incompatible list
|
|
38
|
+
if "incompatible_with" in compat_rules:
|
|
39
|
+
if other.id in compat_rules["incompatible_with"]:
|
|
40
|
+
return False
|
|
41
|
+
if other.type in compat_rules["incompatible_with"]:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
# Default: permissive licenses are generally compatible
|
|
45
|
+
if self.type == "permissive" and other.type == "permissive":
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
def get_obligations(self) -> List[str]:
|
|
51
|
+
"""Get all obligations for this license."""
|
|
52
|
+
obligations = []
|
|
53
|
+
|
|
54
|
+
if self.requirements.get("disclose_source"):
|
|
55
|
+
obligations.append("Disclose source code")
|
|
56
|
+
|
|
57
|
+
if self.requirements.get("include_license"):
|
|
58
|
+
obligations.append("Include license text")
|
|
59
|
+
|
|
60
|
+
if self.requirements.get("include_copyright"):
|
|
61
|
+
obligations.append("Include copyright notice")
|
|
62
|
+
|
|
63
|
+
if self.requirements.get("state_changes"):
|
|
64
|
+
obligations.append("State changes made to the code")
|
|
65
|
+
|
|
66
|
+
if self.requirements.get("same_license"):
|
|
67
|
+
obligations.append("Distribute under same license")
|
|
68
|
+
|
|
69
|
+
return obligations
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_dict(cls, data: Dict[str, Any]) -> "License":
|
|
73
|
+
"""Create a License instance from a dictionary."""
|
|
74
|
+
return cls(
|
|
75
|
+
id=data["id"],
|
|
76
|
+
name=data["name"],
|
|
77
|
+
type=data["type"],
|
|
78
|
+
spdx_id=data.get("spdx_id"),
|
|
79
|
+
properties=data.get("properties", {}),
|
|
80
|
+
requirements=data.get("requirements", {}),
|
|
81
|
+
compatibility=data.get("compatibility", {}),
|
|
82
|
+
)
|
ospac/models/policy.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Policy data model.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Dict, List, Any, Optional
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Rule:
|
|
11
|
+
"""Represents a single policy rule."""
|
|
12
|
+
|
|
13
|
+
id: str
|
|
14
|
+
description: str
|
|
15
|
+
when: Dict[str, Any]
|
|
16
|
+
then: Dict[str, Any]
|
|
17
|
+
priority: int = 0
|
|
18
|
+
|
|
19
|
+
def matches(self, context: Dict[str, Any]) -> bool:
|
|
20
|
+
"""Check if this rule matches the given context."""
|
|
21
|
+
for key, expected in self.when.items():
|
|
22
|
+
if key not in context:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
actual = context[key]
|
|
26
|
+
if isinstance(expected, list):
|
|
27
|
+
if actual not in expected:
|
|
28
|
+
return False
|
|
29
|
+
elif actual != expected:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Policy:
|
|
37
|
+
"""Represents a compliance policy."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
version: str
|
|
41
|
+
rules: List[Rule] = field(default_factory=list)
|
|
42
|
+
extends: Optional[str] = None
|
|
43
|
+
decision_tree: Optional[List[Dict]] = None
|
|
44
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
def add_rule(self, rule: Rule) -> None:
|
|
47
|
+
"""Add a rule to this policy."""
|
|
48
|
+
self.rules.append(rule)
|
|
49
|
+
# Sort by priority
|
|
50
|
+
self.rules.sort(key=lambda r: r.priority, reverse=True)
|
|
51
|
+
|
|
52
|
+
def evaluate(self, context: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
53
|
+
"""Evaluate this policy against the given context."""
|
|
54
|
+
results = []
|
|
55
|
+
|
|
56
|
+
for rule in self.rules:
|
|
57
|
+
if rule.matches(context):
|
|
58
|
+
result = {
|
|
59
|
+
"rule_id": rule.id,
|
|
60
|
+
"description": rule.description,
|
|
61
|
+
"action": rule.then.get("action", "allow"),
|
|
62
|
+
"severity": rule.then.get("severity", "info"),
|
|
63
|
+
"message": rule.then.get("message", ""),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if "requirements" in rule.then:
|
|
67
|
+
result["requirements"] = rule.then["requirements"]
|
|
68
|
+
|
|
69
|
+
if "remediation" in rule.then:
|
|
70
|
+
result["remediation"] = rule.then["remediation"]
|
|
71
|
+
|
|
72
|
+
results.append(result)
|
|
73
|
+
|
|
74
|
+
return results
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Policy":
|
|
78
|
+
"""Create a Policy instance from a dictionary."""
|
|
79
|
+
rules = []
|
|
80
|
+
for rule_data in data.get("rules", []):
|
|
81
|
+
rule = Rule(
|
|
82
|
+
id=rule_data["id"],
|
|
83
|
+
description=rule_data.get("description", ""),
|
|
84
|
+
when=rule_data.get("when", {}),
|
|
85
|
+
then=rule_data.get("then", {}),
|
|
86
|
+
priority=rule_data.get("priority", 0),
|
|
87
|
+
)
|
|
88
|
+
rules.append(rule)
|
|
89
|
+
|
|
90
|
+
return cls(
|
|
91
|
+
name=data.get("name", "unnamed"),
|
|
92
|
+
version=data.get("version", "1.0"),
|
|
93
|
+
rules=rules,
|
|
94
|
+
extends=data.get("extends"),
|
|
95
|
+
decision_tree=data.get("decision_tree"),
|
|
96
|
+
metadata=data.get("metadata", {}),
|
|
97
|
+
)
|