@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.
@@ -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()