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.
- package/AGENTS.MD +481 -0
- package/LICENSE +21 -0
- package/README.md +319 -0
- package/bin/sage.js +55 -0
- package/claude.json +16 -0
- package/codex.json +22 -0
- package/cursor.json +27 -0
- package/docs/architecture.md +38 -0
- package/opencode.json +24 -0
- package/package.json +58 -0
- package/requirements.txt +7 -0
- package/rules/general/EU_AI_Act_Annex_III.md +29 -0
- package/rules/general/OECD_Principles.md +20 -0
- package/rules/general/UNESCO_AI_Ethics.md +237 -0
- package/rules/general/UN_Human_Rights.md +183 -0
- package/rules/index.json +145 -0
- package/sage/mcp_server.py +459 -0
- package/sage/report_gen.py +408 -0
- package/sage/sage_agent.py +710 -0
- package/sage/security_agent.py +455 -0
- package/sage/startup.py +311 -0
|
@@ -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
|
+
)
|