@softspark/ai-toolkit 1.4.2 → 1.5.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/CHANGELOG.md +17 -0
- package/README.md +5 -4
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +2 -1
- package/app/skills/hipaa-validate/SKILL.md +345 -0
- package/app/skills/hipaa-validate/reference/hipaa-rules.md +303 -0
- package/app/skills/hipaa-validate/reference/phi-identifiers.md +45 -0
- package/app/skills/hipaa-validate/scripts/hipaa_scan.py +752 -0
- package/kb/procedures/maintenance-sop.md +43 -19
- package/kb/procedures/release-preparation-sop.md +277 -0
- package/kb/reference/agents-catalog.md +2 -2
- package/kb/reference/architecture-overview.md +3 -3
- package/kb/reference/distribution-model.md +2 -2
- package/kb/reference/global-install-model.md +7 -3
- package/kb/reference/hooks-catalog.md +3 -3
- package/kb/reference/skills-catalog.md +4 -3
- package/llms-full.txt +66 -36
- package/manifest.json +1 -1
- package/package.json +2 -2
- package/scripts/install.py +51 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""HIPAA compliance scanner — pattern-matching heuristics for PHI exposure.
|
|
3
|
+
|
|
4
|
+
Stdlib only. No external dependencies.
|
|
5
|
+
Scans codebase for HIPAA violations across 8 check categories.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import fnmatch
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Constants
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
DEFAULT_KEYWORDS = [
|
|
21
|
+
"patient", "diagnosis", "medication", "clinical", "healthcare",
|
|
22
|
+
"medical", "fhir", "hl7", "hipaa", "phi", "protected.health",
|
|
23
|
+
"health-record", "health-plan", "health-insurance",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
SKIP_DIRS = {
|
|
27
|
+
"node_modules", "vendor", ".git", "dist", "build", "out", ".next",
|
|
28
|
+
"__pycache__", ".venv", "venv", ".tox", ".mypy_cache",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
SKIP_EXTENSIONS = {
|
|
32
|
+
".lock", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
|
|
33
|
+
".woff", ".woff2", ".ttf", ".eot", ".mp3", ".mp4", ".zip",
|
|
34
|
+
".tar", ".gz", ".bin", ".exe", ".dll", ".so", ".dylib", ".pyc",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
SKIP_FILES = {
|
|
38
|
+
"package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
|
39
|
+
"Cargo.lock", "Gemfile.lock", "poetry.lock", "composer.lock",
|
|
40
|
+
# IDE/editor/AI tool configs (contain pattern examples, not source code)
|
|
41
|
+
".roomodes", ".cursorrules", ".windsurfrules",
|
|
42
|
+
"llms.txt", "llms-full.txt", "AGENTS.md",
|
|
43
|
+
"CLAUDE.md", "GEMINI.md", "COPILOT.md",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
TEST_DIRS = {
|
|
47
|
+
"test", "tests", "__tests__", "spec", "fixtures",
|
|
48
|
+
"mocks", "__mocks__", "testdata", "test-data",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
LANGUAGE_INDICATORS = {
|
|
52
|
+
"package.json": "js",
|
|
53
|
+
"tsconfig.json": "ts",
|
|
54
|
+
"requirements.txt": "python",
|
|
55
|
+
"pyproject.toml": "python",
|
|
56
|
+
"setup.py": "python",
|
|
57
|
+
"go.mod": "go",
|
|
58
|
+
"Cargo.toml": "rust",
|
|
59
|
+
"pom.xml": "java",
|
|
60
|
+
"build.gradle": "java",
|
|
61
|
+
"Gemfile": "ruby",
|
|
62
|
+
"*.csproj": "csharp",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Pattern definitions per category
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
# Category 1: PHI in Logs/Console Output
|
|
70
|
+
CAT1_PATTERNS = [
|
|
71
|
+
# JS/TS
|
|
72
|
+
(r"console\.log\(.*patient", "HIGH", "js", "Patient data in console.log", "1"),
|
|
73
|
+
(r"console\.\w+\(.*req\.body", "WARN", "js", "Raw request body may contain PHI", "1"),
|
|
74
|
+
(r"JSON\.stringify\(.*patient", "WARN", "js", "Full patient object serialization", "1"),
|
|
75
|
+
# Python
|
|
76
|
+
(r"print\(.*\b(patient|ssn|social.security)", "HIGH", "python", "PHI in print statement", "1"),
|
|
77
|
+
(r"logging\.\w+\(.*\b(patient|ssn|mrn|dob)", "HIGH", "python", "PHI fields in logger", "1"),
|
|
78
|
+
# Go
|
|
79
|
+
(r"fmt\.Print.*\b(patient|ssn|mrn)", "HIGH", "go", "PHI in fmt output", "1"),
|
|
80
|
+
(r"log\.\w+\(.*\b(patient|ssn|mrn)", "HIGH", "go", "PHI in log call", "1"),
|
|
81
|
+
# Java
|
|
82
|
+
(r"System\.out\.print.*\b(patient|ssn|mrn)", "HIGH", "java", "PHI in stdout", "1"),
|
|
83
|
+
(r"logger\.\w+\(.*\b(patient|ssn|mrn|dob)", "HIGH", "java", "PHI in logger", "1"),
|
|
84
|
+
# Ruby
|
|
85
|
+
(r"puts.*\b(patient|ssn|mrn)", "HIGH", "ruby", "PHI in puts", "1"),
|
|
86
|
+
(r"Rails\.logger.*\b(patient|ssn|mrn)", "HIGH", "ruby", "PHI in Rails logger", "1"),
|
|
87
|
+
# C#
|
|
88
|
+
(r"Console\.Write.*\b(patient|ssn|mrn)", "HIGH", "csharp", "PHI in Console output", "1"),
|
|
89
|
+
(r"_logger\.\w+\(.*\b(patient|ssn|mrn|dob)", "HIGH", "csharp", "PHI in ILogger", "1"),
|
|
90
|
+
# Minimum Necessary (any language)
|
|
91
|
+
(r"SELECT\s+\*.*FROM.*(patient|member|enrollee)", "WARN", "any", "SELECT * on PHI table violates minimum necessary", "1"),
|
|
92
|
+
(r"json\.dumps\(.*patient", "WARN", "python", "Full patient object serialization", "1"),
|
|
93
|
+
(r"JsonConvert\.Serialize.*patient", "WARN", "csharp", "Full patient object serialization", "1"),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
# Category 3: Unencrypted PHI Transmission
|
|
97
|
+
CAT3_PATTERNS = [
|
|
98
|
+
(r"http://(?!localhost|127\.0\.0\.1|0\.0\.0\.0)", "HIGH", "any", "Unencrypted HTTP — use HTTPS", "3"),
|
|
99
|
+
(r"rejectUnauthorized:\s*false", "HIGH", "any", "TLS verification disabled", "3"),
|
|
100
|
+
(r"ws://(?!localhost|127\.0\.0\.1)", "WARN", "any", "Unencrypted WebSocket", "3"),
|
|
101
|
+
(r"NODE_TLS_REJECT_UNAUTHORIZED.*0", "HIGH", "js", "TLS rejection disabled globally", "3"),
|
|
102
|
+
(r"verify\s*=\s*False", "HIGH", "python", "TLS verification disabled (requests)", "3"),
|
|
103
|
+
(r"InsecureRequestWarning", "WARN", "python", "TLS warning suppressed", "3"),
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
# Category 4: Hardcoded PHI/Test Data
|
|
107
|
+
CAT4_PATTERNS = [
|
|
108
|
+
(r"\d{3}-\d{2}-\d{4}", "HIGH", "any", "Possible hardcoded SSN", "4"),
|
|
109
|
+
(r"\b(mrn|medical.record.number|medicalRecordNumber|medical_record)\b\s*[:=]", "HIGH", "any", "MRN assignment in source", "4"),
|
|
110
|
+
(r"\b\d{5}(-\d{4})?\b(?=.*\b(zip|postal|address)\b)", "WARN", "any", "ZIP code near address context", "4"),
|
|
111
|
+
(r"\b(dob|dateOfBirth|birthDate|birth_date|date_of_birth)\b\s*[:=]", "WARN", "any", "Date of birth assignment", "4"),
|
|
112
|
+
(r"\d{3}[\s.-]?\d{3}[\s.-]?\d{4}(?=.*\b(phone|tel|mobile|cell|contact)\b)", "WARN", "any", "Phone number in healthcare context", "4"),
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
# Category 7: Encryption at Rest
|
|
116
|
+
CAT7_PATTERNS = [
|
|
117
|
+
(r"encrypt\s*[:=]\s*false", "HIGH", "any", "Encryption explicitly disabled", "7"),
|
|
118
|
+
(r"localStorage\.setItem\(.*\b(patient|ssn|mrn|phi|health)", "HIGH", "js", "PHI in unencrypted browser storage", "7"),
|
|
119
|
+
(r"sessionStorage\.setItem\(.*\b(patient|ssn|mrn|phi|health)", "HIGH", "js", "PHI in session storage", "7"),
|
|
120
|
+
(r"SharedPreferences.*\b(patient|ssn|mrn|phi|health)", "HIGH", "java", "PHI in unencrypted mobile storage", "7"),
|
|
121
|
+
(r"UserDefaults.*\b(patient|ssn|mrn|phi|health)", "HIGH", "any", "PHI in unencrypted UserDefaults", "7"),
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
# Category 8: PHI Temp File Exposure
|
|
125
|
+
CAT8_PATTERNS = [
|
|
126
|
+
(r"/tmp/.*\b(patient|phi|health|medical)", "WARN", "any", "Temp file with PHI — ensure secure deletion", "8"),
|
|
127
|
+
(r"(tempfile\.|os\.tmpdir|Path\.GetTempPath|mktemp|NamedTemporaryFile|createTempFile)", "WARN", "any", "Temp file creation near PHI context", "8"),
|
|
128
|
+
(r"(cache/|\.cache|Cache\.set).*\b(patient|phi|health)", "WARN", "any", "Cached PHI — ensure encryption or purge schedule", "8"),
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
# Category 5: Access Control Gaps (heuristic, compliance only)
|
|
132
|
+
CAT5_AUTH_KEYWORDS = [
|
|
133
|
+
"auth", "authenticate", "requireAuth", "isAuthenticated",
|
|
134
|
+
"protect", "guard", "Authorize", "login_required", "Permission",
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
CAT5_PATTERNS = [
|
|
138
|
+
(r"Access-Control-Allow-Origin:\s*\*", "HIGH", "any", "Unrestricted CORS on PHI endpoint", "5"),
|
|
139
|
+
(r"origin:\s*(true|\*)", "HIGH", "any", "Unrestricted CORS origin", "5"),
|
|
140
|
+
(r"\b(public|noAuth|anonymous)\b.*\b(patient|phi|health|medical)", "HIGH", "any", "Public route exposing PHI", "5"),
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
# HIPAA rule citations per category
|
|
144
|
+
HIPAA_RULES = {
|
|
145
|
+
"1": "§164.502(b) — Minimum Necessary Standard",
|
|
146
|
+
"2": "§164.312(b) — Audit Controls",
|
|
147
|
+
"3": "§164.312(e)(1) — Transmission Security",
|
|
148
|
+
"4": "§164.514(b)(2) — De-identification (Safe Harbor)",
|
|
149
|
+
"5": "§164.312(a)(1) / §164.312(d) — Access Control / Authentication",
|
|
150
|
+
"6": "§164.308(b)(1) / §164.314(a) — Business Associate Contracts",
|
|
151
|
+
"7": "§164.312(a)(2)(iv) — Encryption and Decryption",
|
|
152
|
+
"8": "§164.310(d)(2)(iii) — Device and Media Controls: Disposal",
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# File traversal
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def _self_dir() -> Path:
|
|
161
|
+
"""Return the hipaa-validate skill directory (parent of scripts/)."""
|
|
162
|
+
return Path(__file__).resolve().parent.parent
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def should_skip_path(path: Path, self_exclude: Path | None = None) -> bool:
|
|
166
|
+
"""Check if a path should be skipped based on directory/file rules."""
|
|
167
|
+
parts = path.parts
|
|
168
|
+
for part in parts:
|
|
169
|
+
if part in SKIP_DIRS:
|
|
170
|
+
return True
|
|
171
|
+
if path.name in SKIP_FILES:
|
|
172
|
+
return True
|
|
173
|
+
if path.suffix.lower() in SKIP_EXTENSIONS:
|
|
174
|
+
return True
|
|
175
|
+
# Self-exclusion: skip the scanner's own skill directory
|
|
176
|
+
if self_exclude:
|
|
177
|
+
try:
|
|
178
|
+
path.resolve().relative_to(self_exclude)
|
|
179
|
+
return True
|
|
180
|
+
except ValueError:
|
|
181
|
+
pass
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def is_test_path(path: Path) -> bool:
|
|
186
|
+
"""Check if path is inside a test directory."""
|
|
187
|
+
for part in path.parts:
|
|
188
|
+
if part in TEST_DIRS:
|
|
189
|
+
return True
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def load_hipaaignore(root: Path) -> list[str]:
|
|
194
|
+
"""Load .hipaaignore patterns from project root."""
|
|
195
|
+
ignore_file = root / ".hipaaignore"
|
|
196
|
+
if not ignore_file.exists():
|
|
197
|
+
return []
|
|
198
|
+
patterns = []
|
|
199
|
+
for line in ignore_file.read_text(errors="replace").splitlines():
|
|
200
|
+
line = line.strip()
|
|
201
|
+
if line and not line.startswith("#"):
|
|
202
|
+
patterns.append(line)
|
|
203
|
+
return patterns
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def matches_ignore(rel_path: str, patterns: list[str]) -> bool:
|
|
207
|
+
"""Check if a relative path matches any .hipaaignore pattern."""
|
|
208
|
+
for pattern in patterns:
|
|
209
|
+
if fnmatch.fnmatch(rel_path, pattern):
|
|
210
|
+
return True
|
|
211
|
+
# Also check each path component for directory patterns
|
|
212
|
+
if pattern.endswith("/**") and rel_path.startswith(pattern[:-3]):
|
|
213
|
+
return True
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def collect_source_files(root: Path, scan_path: Path | None = None) -> list[Path]:
|
|
218
|
+
"""Collect all source files under root, respecting skip rules."""
|
|
219
|
+
target = scan_path or root
|
|
220
|
+
self_dir = _self_dir()
|
|
221
|
+
files = []
|
|
222
|
+
for dirpath, dirnames, filenames in os.walk(target):
|
|
223
|
+
# Prune skip dirs in-place
|
|
224
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
225
|
+
# Prune self directory (scanner's own skill folder)
|
|
226
|
+
dp = Path(dirpath).resolve()
|
|
227
|
+
try:
|
|
228
|
+
dp.relative_to(self_dir)
|
|
229
|
+
continue
|
|
230
|
+
except ValueError:
|
|
231
|
+
pass
|
|
232
|
+
for fname in filenames:
|
|
233
|
+
fpath = Path(dirpath) / fname
|
|
234
|
+
if not should_skip_path(fpath, self_dir):
|
|
235
|
+
files.append(fpath)
|
|
236
|
+
return files
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# Context gate (Step 0)
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def context_gate(files: list[Path], keywords: list[str],
|
|
244
|
+
ignore_patterns: list[str], root: Path) -> list[Path]:
|
|
245
|
+
"""Identify PHI-adjacent files containing healthcare keywords."""
|
|
246
|
+
phi_files = []
|
|
247
|
+
kw_pattern = re.compile("|".join(re.escape(kw) for kw in keywords), re.IGNORECASE)
|
|
248
|
+
|
|
249
|
+
for fpath in files:
|
|
250
|
+
rel = str(fpath.relative_to(root))
|
|
251
|
+
if matches_ignore(rel, ignore_patterns):
|
|
252
|
+
continue
|
|
253
|
+
try:
|
|
254
|
+
content = fpath.read_text(errors="replace")
|
|
255
|
+
except (OSError, PermissionError):
|
|
256
|
+
continue
|
|
257
|
+
if kw_pattern.search(content):
|
|
258
|
+
phi_files.append(fpath)
|
|
259
|
+
return phi_files
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# Language detection (Step 1)
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
def detect_languages(root: Path) -> set[str]:
|
|
267
|
+
"""Detect project languages from manifest files."""
|
|
268
|
+
langs = set()
|
|
269
|
+
for indicator, lang in LANGUAGE_INDICATORS.items():
|
|
270
|
+
if "*" in indicator:
|
|
271
|
+
# Glob-style
|
|
272
|
+
for _ in root.glob(indicator):
|
|
273
|
+
langs.add(lang)
|
|
274
|
+
break
|
|
275
|
+
else:
|
|
276
|
+
if (root / indicator).exists():
|
|
277
|
+
langs.add(lang)
|
|
278
|
+
return langs or {"any"}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
# Category scanners (Step 2)
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
def scan_patterns(files: list[Path], patterns: list[tuple],
|
|
286
|
+
languages: set[str], root: Path) -> list[dict]:
|
|
287
|
+
"""Run regex patterns against files, return findings."""
|
|
288
|
+
findings = []
|
|
289
|
+
for fpath in files:
|
|
290
|
+
try:
|
|
291
|
+
lines = fpath.read_text(errors="replace").splitlines()
|
|
292
|
+
except (OSError, PermissionError):
|
|
293
|
+
continue
|
|
294
|
+
rel = str(fpath.relative_to(root))
|
|
295
|
+
|
|
296
|
+
for line_num, line in enumerate(lines, 1):
|
|
297
|
+
for pat, severity, lang, desc, cat in patterns:
|
|
298
|
+
if lang != "any" and lang not in languages:
|
|
299
|
+
continue
|
|
300
|
+
if re.search(pat, line, re.IGNORECASE):
|
|
301
|
+
findings.append({
|
|
302
|
+
"file": rel,
|
|
303
|
+
"line": line_num,
|
|
304
|
+
"severity": severity,
|
|
305
|
+
"category": int(cat),
|
|
306
|
+
"category_name": _cat_name(int(cat)),
|
|
307
|
+
"confidence": "definitive",
|
|
308
|
+
"pattern": line.strip()[:120],
|
|
309
|
+
"description": desc,
|
|
310
|
+
"hipaa_rule": HIPAA_RULES.get(cat, ""),
|
|
311
|
+
})
|
|
312
|
+
return findings
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _cat_name(cat: int) -> str:
|
|
316
|
+
names = {
|
|
317
|
+
1: "PHI in Logs",
|
|
318
|
+
2: "Missing Audit Logging",
|
|
319
|
+
3: "Unencrypted PHI Transmission",
|
|
320
|
+
4: "Hardcoded PHI/Test Data",
|
|
321
|
+
5: "Access Control Gaps",
|
|
322
|
+
6: "Missing BAA References",
|
|
323
|
+
7: "Encryption at Rest",
|
|
324
|
+
8: "PHI Temp File Exposure",
|
|
325
|
+
}
|
|
326
|
+
return names.get(cat, f"Category {cat}")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def scan_cat2_audit_gaps(phi_files: list[Path], root: Path) -> list[dict]:
|
|
330
|
+
"""Category 2: Missing Audit Logging (heuristic)."""
|
|
331
|
+
data_ops = re.compile(
|
|
332
|
+
r"(router|app\.(get|post|put|delete|patch)|"
|
|
333
|
+
r"@(Request|Get|Post|Put|Delete)Mapping|"
|
|
334
|
+
r"Model\.(find|save|update|delete)|"
|
|
335
|
+
r"db\.(query|execute)|cursor\.execute|"
|
|
336
|
+
r"repository\.|findBy|\.save\(|\.delete\()",
|
|
337
|
+
re.IGNORECASE,
|
|
338
|
+
)
|
|
339
|
+
audit_kw = re.compile(
|
|
340
|
+
r"(audit|AuditEvent|auditLog|logAccess|logEvent|"
|
|
341
|
+
r"createAuditEntry|recordAccess|ActivityLog|trail|writeAudit)",
|
|
342
|
+
re.IGNORECASE,
|
|
343
|
+
)
|
|
344
|
+
bulk_ops = re.compile(r"\b(export|download|bulk|batch)\b", re.IGNORECASE)
|
|
345
|
+
|
|
346
|
+
findings = []
|
|
347
|
+
for fpath in phi_files:
|
|
348
|
+
try:
|
|
349
|
+
content = fpath.read_text(errors="replace")
|
|
350
|
+
except (OSError, PermissionError):
|
|
351
|
+
continue
|
|
352
|
+
rel = str(fpath.relative_to(root))
|
|
353
|
+
|
|
354
|
+
if not data_ops.search(content):
|
|
355
|
+
continue
|
|
356
|
+
if audit_kw.search(content):
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
desc = "POTENTIAL audit gap: PHI route file without audit keywords"
|
|
360
|
+
if bulk_ops.search(content):
|
|
361
|
+
desc = "POTENTIAL audit gap: bulk PHI operation without audit trail"
|
|
362
|
+
|
|
363
|
+
findings.append({
|
|
364
|
+
"file": rel,
|
|
365
|
+
"line": 1,
|
|
366
|
+
"severity": "HIGH",
|
|
367
|
+
"category": 2,
|
|
368
|
+
"category_name": "Missing Audit Logging",
|
|
369
|
+
"confidence": "heuristic",
|
|
370
|
+
"pattern": "(co-occurrence check — may be false positive)",
|
|
371
|
+
"description": desc,
|
|
372
|
+
"hipaa_rule": HIPAA_RULES["2"],
|
|
373
|
+
})
|
|
374
|
+
return findings
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def scan_cat5_access_gaps(phi_files: list[Path], languages: set[str],
|
|
378
|
+
root: Path) -> list[dict]:
|
|
379
|
+
"""Category 5: Access Control Gaps (heuristic + definitive patterns)."""
|
|
380
|
+
auth_kw = re.compile(
|
|
381
|
+
"|".join(re.escape(k) for k in CAT5_AUTH_KEYWORDS), re.IGNORECASE,
|
|
382
|
+
)
|
|
383
|
+
data_ops = re.compile(
|
|
384
|
+
r"(router|app\.(get|post|put|delete)|"
|
|
385
|
+
r"@(Request|Get|Post|Put|Delete)Mapping)",
|
|
386
|
+
re.IGNORECASE,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
findings = []
|
|
390
|
+
|
|
391
|
+
# Heuristic: PHI route files without auth keywords
|
|
392
|
+
for fpath in phi_files:
|
|
393
|
+
try:
|
|
394
|
+
content = fpath.read_text(errors="replace")
|
|
395
|
+
except (OSError, PermissionError):
|
|
396
|
+
continue
|
|
397
|
+
rel = str(fpath.relative_to(root))
|
|
398
|
+
|
|
399
|
+
if not data_ops.search(content):
|
|
400
|
+
continue
|
|
401
|
+
if auth_kw.search(content):
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
findings.append({
|
|
405
|
+
"file": rel,
|
|
406
|
+
"line": 1,
|
|
407
|
+
"severity": "WARN",
|
|
408
|
+
"category": 5,
|
|
409
|
+
"category_name": "Access Control Gaps",
|
|
410
|
+
"confidence": "heuristic",
|
|
411
|
+
"pattern": "(co-occurrence check — may be false positive)",
|
|
412
|
+
"description": "POTENTIAL access control gap — verify auth middleware covers these routes",
|
|
413
|
+
"hipaa_rule": HIPAA_RULES["5"],
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
# Definitive patterns
|
|
417
|
+
findings.extend(scan_patterns(phi_files, CAT5_PATTERNS, languages, root))
|
|
418
|
+
return findings
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def scan_cat6_baa(phi_files: list[Path], root: Path) -> list[dict]:
|
|
422
|
+
"""Category 6: Missing BAA References — BAA Verification Checklist."""
|
|
423
|
+
# Load covered vendors
|
|
424
|
+
covered = set()
|
|
425
|
+
config_file = root / ".hipaa-config"
|
|
426
|
+
if config_file.exists():
|
|
427
|
+
try:
|
|
428
|
+
cfg = json.loads(config_file.read_text(errors="replace"))
|
|
429
|
+
covered = {v.lower() for v in cfg.get("covered_vendors", [])}
|
|
430
|
+
except (json.JSONDecodeError, ValueError):
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
service_patterns = [
|
|
434
|
+
(r"(fetch|axios|requests|http\.Get|HttpClient|RestTemplate|urllib)\s*\(", "HTTP client call"),
|
|
435
|
+
(r"(S3|GCS|BlobStorage|putObject|upload)(?!\w)", "Cloud storage"),
|
|
436
|
+
(r"(mongodb\+srv://|postgres://|mysql://|firestore|dynamodb|CosmosClient|MongoClient)", "Cloud database"),
|
|
437
|
+
(r"(SQS|SNS|RabbitMQ|redis://|kafka|EventBridge|PubSub)(?!\w)", "Message queue / event streaming"),
|
|
438
|
+
(r"(CloudFront|Cloudflare|Akamai|Fastly|cdn\.)", "CDN"),
|
|
439
|
+
(r"(datadog|splunk|newrelic|sentry|logstash|elasticsearch|bugsnag|rollbar)", "Observability / logging"),
|
|
440
|
+
(r"(analytics\.|gtag|mixpanel|segment|amplitude|posthog)", "Analytics"),
|
|
441
|
+
]
|
|
442
|
+
|
|
443
|
+
services_found: dict[str, dict] = {}
|
|
444
|
+
for fpath in phi_files:
|
|
445
|
+
try:
|
|
446
|
+
content = fpath.read_text(errors="replace")
|
|
447
|
+
except (OSError, PermissionError):
|
|
448
|
+
continue
|
|
449
|
+
rel = str(fpath.relative_to(root))
|
|
450
|
+
|
|
451
|
+
for pat, svc_type in service_patterns:
|
|
452
|
+
for m in re.finditer(pat, content, re.IGNORECASE):
|
|
453
|
+
key = m.group(0).strip("( \t")
|
|
454
|
+
if key.lower() not in services_found:
|
|
455
|
+
services_found[key.lower()] = {
|
|
456
|
+
"service": key,
|
|
457
|
+
"type": svc_type,
|
|
458
|
+
"file": rel,
|
|
459
|
+
"pattern": m.group(0),
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
findings = []
|
|
463
|
+
for key, info in services_found.items():
|
|
464
|
+
is_covered = any(v in key for v in covered)
|
|
465
|
+
status = "covered" if is_covered else "verify_baa"
|
|
466
|
+
|
|
467
|
+
findings.append({
|
|
468
|
+
"file": info["file"],
|
|
469
|
+
"line": 1,
|
|
470
|
+
"severity": "WARN" if not is_covered else "INFO",
|
|
471
|
+
"category": 6,
|
|
472
|
+
"category_name": "Missing BAA References",
|
|
473
|
+
"confidence": "heuristic",
|
|
474
|
+
"pattern": f"{info['type']}: {info['pattern']}",
|
|
475
|
+
"description": f"BAA status: {'covered (covered_vendors)' if is_covered else 'verify BAA exists'} — {info['type']}",
|
|
476
|
+
"hipaa_rule": HIPAA_RULES["6"],
|
|
477
|
+
"baa_status": status,
|
|
478
|
+
})
|
|
479
|
+
return findings
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# ---------------------------------------------------------------------------
|
|
483
|
+
# Report formatters
|
|
484
|
+
# ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
def _table(headers: list[str], rows: list[list[str]]) -> list[str]:
|
|
487
|
+
"""Render an aligned plain-text table."""
|
|
488
|
+
widths = [len(h) for h in headers]
|
|
489
|
+
for row in rows:
|
|
490
|
+
for i, cell in enumerate(row):
|
|
491
|
+
if i < len(widths):
|
|
492
|
+
widths[i] = max(widths[i], len(str(cell)))
|
|
493
|
+
|
|
494
|
+
sep = "+" + "+".join("-" * (w + 2) for w in widths) + "+"
|
|
495
|
+
|
|
496
|
+
def fmt_row(cells: list[str]) -> str:
|
|
497
|
+
parts = []
|
|
498
|
+
for i, cell in enumerate(cells):
|
|
499
|
+
if i < len(widths):
|
|
500
|
+
parts.append(f" {str(cell):<{widths[i]}} ")
|
|
501
|
+
return "|" + "|".join(parts) + "|"
|
|
502
|
+
|
|
503
|
+
lines = [sep, fmt_row(headers), sep]
|
|
504
|
+
for row in rows:
|
|
505
|
+
lines.append(fmt_row(row))
|
|
506
|
+
lines.append(sep)
|
|
507
|
+
return lines
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def format_text_report(findings: list[dict], mode: str,
|
|
511
|
+
phi_count: int, scanned_count: int,
|
|
512
|
+
categories_run: list[int]) -> str:
|
|
513
|
+
"""Format findings as a terminal-friendly report."""
|
|
514
|
+
high_count = sum(1 for f in findings if f["severity"] == "HIGH")
|
|
515
|
+
warn_count = sum(1 for f in findings if f["severity"] == "WARN")
|
|
516
|
+
info_count = sum(1 for f in findings if f["severity"] == "INFO")
|
|
517
|
+
|
|
518
|
+
lines = ["", "=== HIPAA Validation Report ===", ""]
|
|
519
|
+
|
|
520
|
+
# Summary table
|
|
521
|
+
summary_rows = [
|
|
522
|
+
["Mode", mode],
|
|
523
|
+
["PHI-adjacent files", str(phi_count)],
|
|
524
|
+
["Files scanned", str(scanned_count)],
|
|
525
|
+
["Categories run", ",".join(str(c) for c in sorted(categories_run))],
|
|
526
|
+
["HIGH", str(high_count)],
|
|
527
|
+
["WARN", str(warn_count)],
|
|
528
|
+
]
|
|
529
|
+
if info_count:
|
|
530
|
+
summary_rows.append(["INFO", str(info_count)])
|
|
531
|
+
lines.extend(_table(["Metric", "Value"], summary_rows))
|
|
532
|
+
lines.append("")
|
|
533
|
+
|
|
534
|
+
if not findings:
|
|
535
|
+
lines.append("No HIPAA compliance issues found.")
|
|
536
|
+
lines.append("")
|
|
537
|
+
return "\n".join(lines)
|
|
538
|
+
|
|
539
|
+
# Sort: HIGH first, then WARN, then INFO
|
|
540
|
+
sev_order = {"HIGH": 0, "WARN": 1, "INFO": 2}
|
|
541
|
+
findings_sorted = sorted(findings, key=lambda f: (sev_order.get(f["severity"], 9), f["file"]))
|
|
542
|
+
|
|
543
|
+
# Findings
|
|
544
|
+
for f in findings_sorted:
|
|
545
|
+
lines.append(f"[{f['severity']}] {f['file']}:{f['line']}")
|
|
546
|
+
lines.append(f" Category: {f['category_name']} (Cat {f['category']})")
|
|
547
|
+
lines.append(f" Confidence: {f['confidence']}")
|
|
548
|
+
lines.append(f" Pattern: {f['pattern']}")
|
|
549
|
+
lines.append(f" HIPAA Rule: {f['hipaa_rule']}")
|
|
550
|
+
lines.append(f" Description: {f['description']}")
|
|
551
|
+
lines.append("")
|
|
552
|
+
|
|
553
|
+
return "\n".join(lines)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def format_json_report(findings: list[dict], mode: str,
|
|
557
|
+
phi_count: int, scanned_count: int,
|
|
558
|
+
categories_run: list[int], languages: list[str]) -> str:
|
|
559
|
+
"""Format findings as structured JSON for CI integration."""
|
|
560
|
+
high_count = sum(1 for f in findings if f["severity"] == "HIGH")
|
|
561
|
+
warn_count = sum(1 for f in findings if f["severity"] == "WARN")
|
|
562
|
+
|
|
563
|
+
report = {
|
|
564
|
+
"summary": {
|
|
565
|
+
"mode": mode,
|
|
566
|
+
"phi_adjacent_files": phi_count,
|
|
567
|
+
"files_scanned": scanned_count,
|
|
568
|
+
"categories_run": sorted(categories_run),
|
|
569
|
+
"languages": sorted(languages),
|
|
570
|
+
"high": high_count,
|
|
571
|
+
"warn": warn_count,
|
|
572
|
+
"total": len(findings),
|
|
573
|
+
},
|
|
574
|
+
"findings": findings,
|
|
575
|
+
"exit_code": 1 if high_count > 0 else 0,
|
|
576
|
+
}
|
|
577
|
+
return json.dumps(report, indent=2)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
# ---------------------------------------------------------------------------
|
|
581
|
+
# Main
|
|
582
|
+
# ---------------------------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
def main() -> None:
|
|
585
|
+
parser = argparse.ArgumentParser(description="HIPAA compliance scanner")
|
|
586
|
+
parser.add_argument("path", nargs="?", default=".",
|
|
587
|
+
help="Project root or subdirectory to scan")
|
|
588
|
+
parser.add_argument("--mode", choices=["developer", "compliance"],
|
|
589
|
+
default="developer",
|
|
590
|
+
help="Scan mode (default: developer)")
|
|
591
|
+
parser.add_argument("--severity", choices=["high", "warn", "all"],
|
|
592
|
+
default="all",
|
|
593
|
+
help="Minimum severity to report (default: all)")
|
|
594
|
+
parser.add_argument("--keywords", type=str, default="",
|
|
595
|
+
help="Comma-separated additional healthcare keywords")
|
|
596
|
+
parser.add_argument("--output", choices=["text", "json"],
|
|
597
|
+
default="text",
|
|
598
|
+
help="Output format (default: text)")
|
|
599
|
+
args = parser.parse_args()
|
|
600
|
+
|
|
601
|
+
scan_path = Path(args.path).resolve()
|
|
602
|
+
# Root is always the git root or the scan path
|
|
603
|
+
root = scan_path
|
|
604
|
+
git_root = _find_git_root(scan_path)
|
|
605
|
+
if git_root:
|
|
606
|
+
root = git_root
|
|
607
|
+
|
|
608
|
+
if not scan_path.exists():
|
|
609
|
+
print(f"Error: {scan_path} does not exist", file=sys.stderr)
|
|
610
|
+
sys.exit(2)
|
|
611
|
+
|
|
612
|
+
# Build keyword list
|
|
613
|
+
keywords = list(DEFAULT_KEYWORDS)
|
|
614
|
+
if args.keywords:
|
|
615
|
+
extra = [k.strip() for k in args.keywords.split(",") if k.strip()]
|
|
616
|
+
keywords.extend(extra)
|
|
617
|
+
|
|
618
|
+
# Load .hipaaignore
|
|
619
|
+
ignore_patterns = load_hipaaignore(root)
|
|
620
|
+
|
|
621
|
+
# Collect files
|
|
622
|
+
all_files = collect_source_files(root, scan_path)
|
|
623
|
+
scanned_count = len(all_files)
|
|
624
|
+
|
|
625
|
+
# Step 0: Context gate
|
|
626
|
+
phi_files = context_gate(all_files, keywords, ignore_patterns, root)
|
|
627
|
+
phi_count = len(phi_files)
|
|
628
|
+
|
|
629
|
+
if phi_count == 0:
|
|
630
|
+
msg = {
|
|
631
|
+
"message": "No healthcare context detected.",
|
|
632
|
+
"keywords_searched": keywords,
|
|
633
|
+
"hint": "If your project uses different terminology, re-run with --keywords member,enrollee,...",
|
|
634
|
+
}
|
|
635
|
+
if args.output == "json":
|
|
636
|
+
report = {
|
|
637
|
+
"summary": {
|
|
638
|
+
"mode": args.mode,
|
|
639
|
+
"phi_adjacent_files": 0,
|
|
640
|
+
"files_scanned": scanned_count,
|
|
641
|
+
"categories_run": [],
|
|
642
|
+
"languages": [],
|
|
643
|
+
"high": 0,
|
|
644
|
+
"warn": 0,
|
|
645
|
+
"total": 0,
|
|
646
|
+
},
|
|
647
|
+
"findings": [],
|
|
648
|
+
"exit_code": 0,
|
|
649
|
+
"info": msg,
|
|
650
|
+
}
|
|
651
|
+
print(json.dumps(report, indent=2))
|
|
652
|
+
else:
|
|
653
|
+
print(f"\nNo healthcare context detected.")
|
|
654
|
+
print(f"Keywords searched: {', '.join(keywords)}")
|
|
655
|
+
print(f"If your project uses different terminology (e.g., member, enrollee, beneficiary),")
|
|
656
|
+
print(f"re-run with: --keywords member,enrollee,...")
|
|
657
|
+
sys.exit(0)
|
|
658
|
+
|
|
659
|
+
# Scope warning
|
|
660
|
+
if phi_count > 50 and args.output == "text":
|
|
661
|
+
print(f"\nWarning: Large scope: {phi_count} PHI-adjacent files detected.",
|
|
662
|
+
file=sys.stderr)
|
|
663
|
+
print("Consider narrowing the scan path for targeted results.", file=sys.stderr)
|
|
664
|
+
|
|
665
|
+
# Step 1: Detect languages
|
|
666
|
+
languages = detect_languages(root)
|
|
667
|
+
|
|
668
|
+
# Step 2: Run categories
|
|
669
|
+
findings: list[dict] = []
|
|
670
|
+
|
|
671
|
+
# Developer mode: 1, 3, 4, 7, 8
|
|
672
|
+
# Compliance mode: all 8
|
|
673
|
+
if args.mode == "developer":
|
|
674
|
+
categories_run = [1, 3, 4, 7, 8]
|
|
675
|
+
else:
|
|
676
|
+
categories_run = [1, 2, 3, 4, 5, 6, 7, 8]
|
|
677
|
+
|
|
678
|
+
# Cat 1: PHI in Logs (full project)
|
|
679
|
+
if 1 in categories_run:
|
|
680
|
+
findings.extend(scan_patterns(all_files, CAT1_PATTERNS, languages, root))
|
|
681
|
+
|
|
682
|
+
# Cat 2: Missing Audit Logging (heuristic, compliance only)
|
|
683
|
+
if 2 in categories_run:
|
|
684
|
+
findings.extend(scan_cat2_audit_gaps(phi_files, root))
|
|
685
|
+
|
|
686
|
+
# Cat 3: Unencrypted Transmission (PHI-adjacent only)
|
|
687
|
+
if 3 in categories_run:
|
|
688
|
+
findings.extend(scan_patterns(phi_files, CAT3_PATTERNS, languages, root))
|
|
689
|
+
|
|
690
|
+
# Cat 4: Hardcoded PHI (PHI-adjacent, skip test dirs)
|
|
691
|
+
if 4 in categories_run:
|
|
692
|
+
non_test_phi = [f for f in phi_files if not is_test_path(f)]
|
|
693
|
+
findings.extend(scan_patterns(non_test_phi, CAT4_PATTERNS, languages, root))
|
|
694
|
+
|
|
695
|
+
# Cat 5: Access Control Gaps (heuristic, compliance only)
|
|
696
|
+
if 5 in categories_run:
|
|
697
|
+
findings.extend(scan_cat5_access_gaps(phi_files, languages, root))
|
|
698
|
+
|
|
699
|
+
# Cat 6: BAA References (compliance only)
|
|
700
|
+
if 6 in categories_run:
|
|
701
|
+
findings.extend(scan_cat6_baa(phi_files, root))
|
|
702
|
+
|
|
703
|
+
# Cat 7: Encryption at Rest (PHI-adjacent)
|
|
704
|
+
if 7 in categories_run:
|
|
705
|
+
findings.extend(scan_patterns(phi_files, CAT7_PATTERNS, languages, root))
|
|
706
|
+
|
|
707
|
+
# Cat 8: Temp File Exposure (PHI-adjacent)
|
|
708
|
+
if 8 in categories_run:
|
|
709
|
+
findings.extend(scan_patterns(phi_files, CAT8_PATTERNS, languages, root))
|
|
710
|
+
|
|
711
|
+
# Severity filter
|
|
712
|
+
if args.severity == "high":
|
|
713
|
+
findings = [f for f in findings if f["severity"] == "HIGH"]
|
|
714
|
+
elif args.severity == "warn":
|
|
715
|
+
findings = [f for f in findings if f["severity"] in ("HIGH", "WARN")]
|
|
716
|
+
|
|
717
|
+
# Deduplicate (same file+line+category)
|
|
718
|
+
seen = set()
|
|
719
|
+
deduped = []
|
|
720
|
+
for f in findings:
|
|
721
|
+
key = (f["file"], f["line"], f["category"], f["description"])
|
|
722
|
+
if key not in seen:
|
|
723
|
+
seen.add(key)
|
|
724
|
+
deduped.append(f)
|
|
725
|
+
findings = deduped
|
|
726
|
+
|
|
727
|
+
# Output
|
|
728
|
+
if args.output == "json":
|
|
729
|
+
print(format_json_report(findings, args.mode, phi_count,
|
|
730
|
+
scanned_count, categories_run,
|
|
731
|
+
sorted(languages)))
|
|
732
|
+
else:
|
|
733
|
+
print(format_text_report(findings, args.mode, phi_count,
|
|
734
|
+
scanned_count, categories_run))
|
|
735
|
+
|
|
736
|
+
# Exit code: non-zero if HIGH findings
|
|
737
|
+
high_count = sum(1 for f in findings if f["severity"] == "HIGH")
|
|
738
|
+
sys.exit(1 if high_count > 0 else 0)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _find_git_root(start: Path) -> Path | None:
|
|
742
|
+
"""Walk up to find .git directory."""
|
|
743
|
+
current = start
|
|
744
|
+
while current != current.parent:
|
|
745
|
+
if (current / ".git").exists():
|
|
746
|
+
return current
|
|
747
|
+
current = current.parent
|
|
748
|
+
return None
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
if __name__ == "__main__":
|
|
752
|
+
main()
|