sage-governance 1.0.0

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,455 @@
1
+ """
2
+ security_agent.py — SAGE Code & Infrastructure Security Agent
3
+ ═════════════════════════════════════════════════════════════
4
+ Full-spectrum, deterministic code scanner. No LLM — results must be
5
+ reproducible across every run. Covers:
6
+
7
+ • API key / secret credential exposure
8
+ • PII field detection (GDPR special categories)
9
+ • Direct protected-attribute use in ML code
10
+ • Proxy attribute discrimination risk
11
+ • EU AI Act compliance gaps (black-box models, missing fairness metrics)
12
+ • Model inversion attack surface
13
+
14
+ SEVERITY SCALE (matches Beunec BAAP P0-P4 system)
15
+ P0 — Critical : secrets in code, biometric/medical PII, direct protected attr
16
+ P1 — High : indirect PII, major compliance gap
17
+ P2 — Medium : proxy risk, black-box model, missing fairness audit
18
+ P3 — Low : data quality, encoding choices, minor gaps
19
+ P4 — Info : best-practice suggestions
20
+
21
+ Author: SAGE Team / Team SAGE (Hackathon)
22
+ License: MIT
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ from dataclasses import dataclass, field
29
+ from typing import Literal
30
+
31
+ from startup import PROTECTED_ATTRIBUTES, PROXY_ATTRIBUTE_MAP
32
+
33
+ # ══════════════════════════════════════════════════════════════════════════════
34
+ # DATA MODELS
35
+ # ══════════════════════════════════════════════════════════════════════════════
36
+
37
+ Severity = Literal["P0", "P1", "P2", "P3", "P4"]
38
+
39
+
40
+ @dataclass
41
+ class SecurityFinding:
42
+ severity: Severity
43
+ category: str
44
+ line_number: int
45
+ snippet: str
46
+ description: str
47
+ fix: str
48
+ regulation: str = ""
49
+
50
+ def to_dict(self) -> dict:
51
+ return {
52
+ "severity": self.severity,
53
+ "category": self.category,
54
+ "line_number": self.line_number,
55
+ "snippet": self.snippet,
56
+ "description": self.description,
57
+ "fix": self.fix,
58
+ "regulation": self.regulation,
59
+ }
60
+
61
+
62
+ @dataclass
63
+ class SecurityReport:
64
+ findings: list[SecurityFinding]
65
+ total_findings: int
66
+ highest_severity: str
67
+ protected_attributes_found: list[str]
68
+ secrets_found: list[str]
69
+ pii_fields_found: list[str]
70
+ passed: bool # True only when zero P0/P1 findings
71
+
72
+ def top_finding(self) -> SecurityFinding | None:
73
+ return self.findings[0] if self.findings else None
74
+
75
+
76
+ # ══════════════════════════════════════════════════════════════════════════════
77
+ # PATTERN BANKS
78
+ # ══════════════════════════════════════════════════════════════════════════════
79
+
80
+ # (regex, description, severity)
81
+ _SECRET_PATTERNS: list[tuple[str, str, Severity]] = [
82
+ # AI provider keys
83
+ (r"sk-ant-[A-Za-z0-9\-_]{40,}", "Anthropic API key hardcoded", "P0"),
84
+ (r"sk-[A-Za-z0-9]{48}", "OpenAI API key hardcoded", "P0"),
85
+ (r"AIza[0-9A-Za-z\-_]{35}", "Google API key hardcoded", "P0"),
86
+ # Generic credentials
87
+ (
88
+ r'(?i)(api[_-]?key|apikey)\s*=\s*["\']([A-Za-z0-9\-_]{20,})["\']',
89
+ "API key hardcoded in source",
90
+ "P0",
91
+ ),
92
+ (
93
+ r'(?i)(secret[_-]?key|client[_-]?secret)\s*=\s*["\']([A-Za-z0-9\-_+/=]{20,})["\']',
94
+ "Secret key hardcoded in source",
95
+ "P0",
96
+ ),
97
+ (
98
+ r'(?i)(password|passwd|pwd)\s*=\s*["\'][^"\']{6,}["\']',
99
+ "Hardcoded password in source",
100
+ "P0",
101
+ ),
102
+ (
103
+ r'(?i)(token|auth[_-]?token|bearer)\s*=\s*["\'][A-Za-z0-9\-_.]{20,}["\']',
104
+ "Hardcoded auth token in source",
105
+ "P0",
106
+ ),
107
+ # Cloud credentials
108
+ (
109
+ r'(?i)(AKIA|ASIA|AROA)[A-Z0-9]{16}',
110
+ "AWS access key ID detected",
111
+ "P0",
112
+ ),
113
+ (
114
+ r'(?i)(aws[_-]?secret[_-]?access[_-]?key)\s*=\s*["\'][A-Za-z0-9/+=]{40}["\']',
115
+ "AWS secret access key hardcoded",
116
+ "P0",
117
+ ),
118
+ (
119
+ r'(?i)(database[_-]?url|db[_-]?url|mongo[_-]?uri|postgres[_-]?url)\s*=\s*["\'][^"\']{10,}["\']',
120
+ "Database connection string hardcoded",
121
+ "P0",
122
+ ),
123
+ (
124
+ r'(?i)(connection[_-]?string)\s*=\s*["\'][^"\']{10,}["\']',
125
+ "Connection string hardcoded",
126
+ "P0",
127
+ ),
128
+ ]
129
+
130
+ # (regex, description, severity)
131
+ _PII_PATTERNS: list[tuple[str, str, Severity]] = [
132
+ (r"\b(ssn|social[_\s]?security[_\s]?number)\b", "Social Security Number field — GDPR Article 9 special category", "P0"),
133
+ (r"\b(biometric|fingerprint|face[_\s]?recognition|voice[_\s]?print|retina)\b", "Biometric data — GDPR Article 9 special category", "P0"),
134
+ (r"\b(medical[_\s]?record|health[_\s]?data|diagnosis|prescription|icd[_\s]?code)\b", "Medical/health data — GDPR Article 9 special category", "P0"),
135
+ (r"\b(passport[_\s]?number|national[_\s]?id[_\s]?number)\b", "Government ID number — high-sensitivity PII", "P1"),
136
+ (r"\b(date[_\s]?of[_\s]?birth|dob|birth[_\s]?date)\b", "Date of birth field", "P1"),
137
+ (r"\b(gps[_\s]?coord|latitude.*longitude|geolocation|location[_\s]?data)\b", "Precise geolocation — privacy risk", "P1"),
138
+ (r"\b(sexual[_\s]?orientation|religion|political[_\s]?opinion)\b", "GDPR Article 9 sensitive category", "P0"),
139
+ (r"\b(phone[_\s]?number|mobile[_\s]?number|cell[_\s]?number)\b", "Phone number field", "P2"),
140
+ (r"\b(email[_\s]?address|email)\b", "Email address field", "P2"),
141
+ (r"\b(ip[_\s]?address|ip[_\s]?addr|ipv4|ipv6)\b", "IP address — personal data under GDPR", "P2"),
142
+ (r"\b(full[_\s]?name|first[_\s]?name|last[_\s]?name|surname)\b", "Name field — PII", "P3"),
143
+ ]
144
+
145
+ # (regex, description, severity)
146
+ _COMPLIANCE_PATTERNS: list[tuple[str, str, Severity]] = [
147
+ (
148
+ r"RandomForestClassifier|GradientBoostingClassifier|XGBClassifier|LGBMClassifier",
149
+ "Black-box model detected — EU AI Act Article 13 requires sufficient transparency "
150
+ "for users to interpret outputs in high-risk systems. Consider Decision Tree (depth≤4) "
151
+ "or GAM for explainability-first approach.",
152
+ "P2",
153
+ ),
154
+ (
155
+ r"accuracy_score\s*\(",
156
+ "Only accuracy_score reported — no fairness metrics detected. "
157
+ "EU AI Act Article 10 requires bias monitoring. "
158
+ "Add: fairlearn.metrics.MetricFrame or demographic_parity_difference.",
159
+ "P2",
160
+ ),
161
+ (
162
+ r"train_test_split\s*\(",
163
+ "Standard train/test split — verify stratify= parameter preserves "
164
+ "demographic representation across splits.",
165
+ "P3",
166
+ ),
167
+ (
168
+ r"\.dropna\(\)",
169
+ "Missing value removal may disproportionately affect minority groups. "
170
+ "Audit which rows are dropped and whether removal is demographically skewed.",
171
+ "P3",
172
+ ),
173
+ (
174
+ r"LabelEncoder\(\)",
175
+ "LabelEncoder imposes arbitrary ordinal ordering on categorical data. "
176
+ "Use OneHotEncoder for nominal categories. Ordinal encoding of 'race' or 'gender' "
177
+ "introduces implicit ranking.",
178
+ "P3",
179
+ ),
180
+ (
181
+ r"pickle\.dump|joblib\.dump",
182
+ "Model serialized without integrity hash. "
183
+ "Add SHA-256 hash of model file to audit trail before deployment.",
184
+ "P3",
185
+ ),
186
+ (
187
+ r'import\s+requests|urllib\.request',
188
+ "External HTTP call in ML code — verify no PII is sent to third-party endpoints "
189
+ "without data processing agreement (GDPR Article 28).",
190
+ "P3",
191
+ ),
192
+ ]
193
+
194
+
195
+ # (regex, description, severity, fix, regulation)
196
+ _SAFEGUARDING_PATTERNS: list[tuple[str, str, Severity, str, str]] = [
197
+ (
198
+ r"\b(auto_escalate|escalate_auto|automatic_escalation|auto_report)\b",
199
+ "Automated escalation/reporting without human review in child safety domain. Auto-reporting without human oversight violates UN CRC Article 3 best interests and EU AI Act human oversight principles.",
200
+ "P0",
201
+ "Implement a human-in-the-loop review queue or verification step before escalating alerts.",
202
+ "UN CRC Article 3 (best interests); EU AI Act Article 14 (human oversight)",
203
+ ),
204
+ (
205
+ r"\b(store_full_conversation|save_chat_history|store_chat_logs|save_full_chat|log_chat_history)\b",
206
+ "Storing full conversation logs/PII of minors. Storing complete chats violates GDPR Article 25 (privacy by design) and data minimization principles.",
207
+ "P0",
208
+ "Store metadata only (e.g. event hashes, timestamps) and discard full chat text immediately after scan.",
209
+ "GDPR Article 5.1.c (data minimization), Article 25 (privacy by design); UN CRC Article 16 (privacy)",
210
+ ),
211
+ ]
212
+
213
+
214
+ # ══════════════════════════════════════════════════════════════════════════════
215
+ # SCAN FUNCTIONS
216
+ # ══════════════════════════════════════════════════════════════════════════════
217
+
218
+
219
+ def _lines(code: str) -> list[str]:
220
+ return code.split("\n")
221
+
222
+
223
+ def _is_comment_line(line: str) -> bool:
224
+ """
225
+ Return True if the line is purely a comment (Python, JS/TS, SQL, shell)
226
+ and therefore should NOT be flagged for protected-attribute or PII
227
+ violations — scanners must never penalise SAGE governance annotations.
228
+
229
+ Examples of safe comment forms:
230
+ # SAGE: race removed from features
231
+ // SAGE: sex excluded via ThresholdOptimizer
232
+ /* GDPR Art.9 — no biometric data */
233
+ -- SQL comment
234
+ """
235
+ stripped = line.strip()
236
+ if not stripped:
237
+ return False
238
+ return (
239
+ stripped.startswith("#") # Python / Shell / YAML
240
+ or stripped.startswith("//") # JS / TS / Java / Go
241
+ or stripped.startswith("/*") # C-style block comment opener
242
+ or stripped.startswith("*") # C-style block comment continuation
243
+ or stripped.startswith("*/") # C-style block comment closer
244
+ or stripped.startswith("--") # SQL single-line comment
245
+ )
246
+
247
+
248
+ def _scan_secrets(code: str) -> list[SecurityFinding]:
249
+ findings: list[SecurityFinding] = []
250
+ for i, line in enumerate(_lines(code), 1):
251
+ for pattern, description, severity in _SECRET_PATTERNS:
252
+ if re.search(pattern, line):
253
+ findings.append(SecurityFinding(
254
+ severity=severity,
255
+ category="SECRET_EXPOSURE",
256
+ line_number=i,
257
+ snippet=line.strip()[:120],
258
+ description=description,
259
+ fix=(
260
+ "Move to environment variable: os.environ.get('KEY_NAME'). "
261
+ "Use python-dotenv for local development. "
262
+ "Never commit secrets to version control."
263
+ ),
264
+ regulation="OWASP A02:2021 Cryptographic Failures; GDPR Article 32",
265
+ ))
266
+ return findings
267
+
268
+
269
+ def _scan_pii(code: str) -> list[SecurityFinding]:
270
+ findings: list[SecurityFinding] = []
271
+ for i, line in enumerate(_lines(code), 1):
272
+ if _is_comment_line(line): # never flag governance annotation comments
273
+ continue
274
+ for pattern, description, severity in _PII_PATTERNS:
275
+ if re.search(pattern, line, re.IGNORECASE):
276
+ findings.append(SecurityFinding(
277
+ severity=severity,
278
+ category="PII_EXPOSURE",
279
+ line_number=i,
280
+ snippet=line.strip()[:120],
281
+ description=f"PII field detected: {description}",
282
+ fix=(
283
+ "Apply data minimization (GDPR Article 5.1.c): "
284
+ "collect only what is strictly necessary. "
285
+ "Pseudonymize or anonymize before model training. "
286
+ "If required for fairness auditing, document legal basis under GDPR Art. 9(2)(g)."
287
+ ),
288
+ regulation="GDPR Article 5 (data minimization), Article 9 (special categories)",
289
+ ))
290
+ return findings
291
+
292
+
293
+ def _scan_protected_attributes(code: str) -> tuple[list[SecurityFinding], list[str]]:
294
+ """Returns (findings, list_of_found_attribute_names)."""
295
+ findings: list[SecurityFinding] = []
296
+ found_attrs: list[str] = []
297
+ lines = _lines(code)
298
+ for i, line in enumerate(lines, 1):
299
+ if _is_comment_line(line): # skip SAGE governance annotation comments
300
+ continue
301
+ for attr in PROTECTED_ATTRIBUTES:
302
+ # Match as standalone variable name or quoted string
303
+ if re.search(
304
+ r"""(?:['"\[])\s*""" + re.escape(attr) + r"""\s*(?:['"\]])""",
305
+ line,
306
+ re.IGNORECASE,
307
+ ) or re.search(r"\b" + re.escape(attr) + r"\b", line, re.IGNORECASE):
308
+ if attr not in found_attrs:
309
+ found_attrs.append(attr)
310
+ findings.append(SecurityFinding(
311
+ severity="P1",
312
+ category="PROTECTED_ATTRIBUTE_DIRECT_USE",
313
+ line_number=i,
314
+ snippet=line.strip()[:120],
315
+ description=f"Protected attribute '{attr}' used directly in model code",
316
+ fix=(
317
+ f"Option A (Remove): Drop '{attr}' from features and audit remaining "
318
+ f"columns for proxy correlation.\n"
319
+ f"Option B (Post-process): Use Fairlearn ThresholdOptimizer — "
320
+ f"'{attr}' used only for threshold calibration at deployment, "
321
+ f"never embedded in model weights."
322
+ ),
323
+ regulation=(
324
+ "EU AI Act Annex III; GDPR Article 9; "
325
+ "UDHR Article 7 (equal protection); "
326
+ "Equal Credit Opportunity Act / Equal Employment Opportunity Act (US)"
327
+ ),
328
+ ))
329
+ return findings, list(set(found_attrs))
330
+
331
+
332
+ def _scan_proxy_attributes(code: str) -> list[SecurityFinding]:
333
+ findings: list[SecurityFinding] = []
334
+ lines = _lines(code)
335
+ seen: set[tuple[int, str]] = set()
336
+ for i, line in enumerate(lines, 1):
337
+ for protected, proxies in PROXY_ATTRIBUTE_MAP.items():
338
+ for proxy in proxies:
339
+ if proxy.lower() in line.lower():
340
+ key = (i, proxy)
341
+ if key not in seen:
342
+ seen.add(key)
343
+ findings.append(SecurityFinding(
344
+ severity="P2",
345
+ category="PROXY_DISCRIMINATION_RISK",
346
+ line_number=i,
347
+ snippet=line.strip()[:120],
348
+ description=(
349
+ f"'{proxy}' is a documented proxy for '{protected}'. "
350
+ "The model may learn indirect discrimination without "
351
+ "any protected attribute appearing in the feature list."
352
+ ),
353
+ fix=(
354
+ f"Audit correlation between '{proxy}' and '{protected}' "
355
+ "using Fairlearn MetricFrame BEFORE training. "
356
+ "Document the correlation coefficient and the decision "
357
+ "to keep or remove the feature in the audit trail."
358
+ ),
359
+ regulation=(
360
+ "EU AI Act Article 10(5) — indirect discrimination; "
361
+ "GDPR Recital 71 (automated profiling)"
362
+ ),
363
+ ))
364
+ return findings
365
+
366
+
367
+ def _scan_compliance(code: str) -> list[SecurityFinding]:
368
+ findings: list[SecurityFinding] = []
369
+ lines = _lines(code)
370
+ for pattern, description, severity in _COMPLIANCE_PATTERNS:
371
+ for i, line in enumerate(lines, 1):
372
+ if re.search(pattern, line):
373
+ findings.append(SecurityFinding(
374
+ severity=severity,
375
+ category="COMPLIANCE_GAP",
376
+ line_number=i,
377
+ snippet=line.strip()[:120],
378
+ description=description,
379
+ fix="Consult SAGE fairness options (sage_evaluate) for compliant alternatives.",
380
+ regulation="EU AI Act Article 13 (transparency); Article 10 (data governance)",
381
+ ))
382
+ break # One finding per compliance pattern type
383
+ return findings
384
+
385
+
386
+ def _scan_safeguarding(code: str) -> list[SecurityFinding]:
387
+ findings: list[SecurityFinding] = []
388
+ lines = _lines(code)
389
+ for pattern, description, severity, fix, regulation in _SAFEGUARDING_PATTERNS:
390
+ for i, line in enumerate(lines, 1):
391
+ if re.search(pattern, line, re.IGNORECASE):
392
+ findings.append(SecurityFinding(
393
+ severity=severity,
394
+ category="SAFEGUARDING_VIOLATION",
395
+ line_number=i,
396
+ snippet=line.strip()[:120],
397
+ description=description,
398
+ fix=fix,
399
+ regulation=regulation,
400
+ ))
401
+ return findings
402
+
403
+
404
+ # ══════════════════════════════════════════════════════════════════════════════
405
+ # PUBLIC API
406
+ # ══════════════════════════════════════════════════════════════════════════════
407
+
408
+
409
+ def scan(code: str) -> SecurityReport:
410
+ """
411
+ Full deterministic security scan. Returns SecurityReport.
412
+
413
+ Severity ordering: P0 > P1 > P2 > P3 > P4
414
+ passed=True only when zero P0/P1 findings.
415
+ """
416
+ secret_findings = _scan_secrets(code)
417
+ pii_findings = _scan_pii(code)
418
+ protected_findings, found_attrs = _scan_protected_attributes(code)
419
+ proxy_findings = _scan_proxy_attributes(code)
420
+ compliance_findings = _scan_compliance(code) or []
421
+ safeguarding_findings = _scan_safeguarding(code)
422
+
423
+ all_findings: list[SecurityFinding] = (
424
+ secret_findings
425
+ + pii_findings
426
+ + protected_findings
427
+ + proxy_findings
428
+ + compliance_findings
429
+ + safeguarding_findings
430
+ )
431
+
432
+ # Deduplicate by (line_number, category, description[:40])
433
+ seen_keys: set[tuple] = set()
434
+ unique: list[SecurityFinding] = []
435
+ for f in all_findings:
436
+ key = (f.line_number, f.category, f.description[:40])
437
+ if key not in seen_keys:
438
+ seen_keys.add(key)
439
+ unique.append(f)
440
+
441
+ _order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3, "P4": 4}
442
+ unique.sort(key=lambda f: _order.get(f.severity, 99))
443
+
444
+ highest = unique[0].severity if unique else "PASS"
445
+ passed = highest not in ("P0", "P1") if unique else True
446
+
447
+ return SecurityReport(
448
+ findings=unique,
449
+ total_findings=len(unique),
450
+ highest_severity=highest,
451
+ protected_attributes_found=found_attrs,
452
+ secrets_found=[f.snippet for f in secret_findings],
453
+ pii_fields_found=[f.description for f in pii_findings],
454
+ passed=passed,
455
+ )