Skip to content

Commit d6e1d56

Browse files
committed
ci: harden security-fix application script
1 parent b669bf1 commit d6e1d56

1 file changed

Lines changed: 46 additions & 101 deletions

File tree

‎.github/workflows/lint-test.yml‎

Lines changed: 46 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -25,108 +25,71 @@ jobs:
2525
ref: ${{ github.event.pull_request.head.ref }}
2626
fetch-depth: 0
2727

28-
- name: Apply exact fixes and behavioral tests
28+
- name: Apply fixes to actual checkout
2929
shell: bash
3030
run: |
3131
set -euo pipefail
3232
python - <<'PY'
3333
from pathlib import Path
3434
import re
3535
36-
# 1) Medical OCR separator fix
36+
# OCR: operate only inside the named dictionary/function, never by global text guessing.
3737
p = Path('hf-space/app_core.py')
3838
s = p.read_text(encoding='utf-8')
39-
if '"ترامادول ": "ترامادول"' in s:
40-
old_dict = '''OCR_CORRECTIONS = {
41-
"باراسيتبمول": "باراسيتامول", "ايبوروفين": "ايبوبروفين",
42-
"اموكسيستلين": "اموكسيسيلين", "اموكسيسلين": "اموكسيسيلين",
43-
"ازيثروميسين": "ازيثرومايسين", "ميتروندازول": "ميترونيدازول",
44-
"ديكلوفيناك ": "ديكلوفيناك", "اوجمينتين": "اوجمنتين",
45-
"اوميبرازول ": "اوميبرازول", "سيليبريكس ": "سيليبريكس",
46-
"ترامادول ": "ترامادول", "كاتافلام ": "كاتافلام",
47-
"نوفافين ": "نوفافين", "فلاميكس ": "فلاميكس",
48-
"بنادول ": "بنادول", "ادفيل ": "ادفيل",
49-
}'''
50-
new_dict = '''OCR_CORRECTIONS = {
51-
"باراسيتبمول": "باراسيتامول", "ايبوروفين": "ايبوبروفين",
52-
"اموكسيستلين": "اموكسيسيلين", "اموكسيسلين": "اموكسيسيلين",
53-
"ازيثروميسين": "ازيثرومايسين", "ميتروندازول": "ميترونيدازول",
54-
"ديكلوفيناك": "ديكلوفيناك", "اوجمينتين": "اوجمنتين",
55-
"اوميبرازول": "اوميبرازول", "سيليبريكس": "سيليبريكس",
56-
"ترامادول": "ترامادول", "كاتافلام": "كاتافلام",
57-
"نوفافين": "نوفافين", "فلاميكس": "فلاميكس",
58-
"بنادول": "بنادول", "ادفيل": "ادفيل",
59-
}'''
60-
if old_dict not in s:
61-
raise SystemExit('Expected original OCR_CORRECTIONS block not found')
62-
s = s.replace(old_dict, new_dict, 1)
63-
64-
old_func = '''def _auto_correct_ocr(text: str) -> Tuple[str, List[Dict]]:
65-
"""Apply OCR corrections + spell checker. Returns (corrected, changes)."""
66-
changes = []
67-
corrected = text
68-
for wrong, right in OCR_CORRECTIONS.items():
69-
if wrong in corrected:
70-
count = corrected.count(wrong)
71-
corrected = corrected.replace(wrong, right)
72-
changes.append({"type": "ocr_fix", "from": wrong, "to": right, "count": count})
73-
corrected = re.sub(r'[ \\t]+', ' ', corrected)
74-
corrected = re.sub(r'\\n{3,}', '\\n\\n', corrected).strip()
75-
return corrected, changes'''
76-
new_func = '''def _auto_correct_ocr(text: str) -> Tuple[str, List[Dict]]:
77-
"""Apply OCR corrections while preserving token boundaries and whitespace."""
78-
changes = []
79-
corrected = text
80-
for wrong, right in OCR_CORRECTIONS.items():
81-
if wrong == right:
82-
continue
83-
pattern = r'(?<!\\w)' + re.escape(wrong) + r'(?!\\w)'
84-
count = len(re.findall(pattern, corrected))
85-
if count:
86-
corrected = re.sub(pattern, right, corrected)
87-
changes.append({"type": "ocr_fix", "from": wrong, "to": right, "count": count})
88-
corrected = re.sub(r'[ \\t]+', ' ', corrected)
89-
corrected = re.sub(r'\\n{3,}', '\\n\\n', corrected).strip()
90-
return corrected, changes'''
91-
if old_func not in s:
92-
raise SystemExit('Expected original _auto_correct_ocr function not found')
93-
s = s.replace(old_func, new_func, 1)
94-
p.write_text(s, encoding='utf-8')
39+
m = re.search(r'OCR_CORRECTIONS = \{.*?\n\}', s, flags=re.S)
40+
if not m:
41+
raise SystemExit('OCR_CORRECTIONS block not found')
42+
block = m.group(0)
43+
block = re.sub(r'("[^"\n]*?)\s+":', r'\1":', block)
44+
s = s[:m.start()] + block + s[m.end():]
45+
46+
old_start = s.index('def _auto_correct_ocr(')
47+
old_end = s.index('\ndef _extract_ner', old_start)
48+
new_func = '''def _auto_correct_ocr(text: str) -> Tuple[str, List[Dict]]:\n """Apply OCR corrections while preserving token boundaries and whitespace."""\n changes = []\n corrected = text\n for wrong, right in OCR_CORRECTIONS.items():\n if wrong == right:\n continue\n pattern = r'(?<!\\w)' + re.escape(wrong) + r'(?!\\w)'\n count = len(re.findall(pattern, corrected))\n if count:\n corrected = re.sub(pattern, right, corrected)\n changes.append({"type": "ocr_fix", "from": wrong, "to": right, "count": count})\n corrected = re.sub(r'[ \\t]+', ' ', corrected)\n corrected = re.sub(r'\\n{3,}', '\\n\\n', corrected).strip()\n return corrected, changes\n'''
49+
s = s[:old_start] + new_func + s[old_end:]
50+
p.write_text(s, encoding='utf-8')
9551
96-
# 2) Authenticate admin status endpoint
52+
# Admin endpoint.
9753
p = Path('app/routers/admin.py')
9854
s = p.read_text(encoding='utf-8')
9955
if 'Depends(get_current_user)' not in s:
100-
s = s.replace('from fastapi import APIRouter\n', 'from fastapi import APIRouter, Depends\nfrom app.core.security import get_current_user\nfrom app.db.models.auth import User\n', 1)
56+
s = s.replace('from fastapi import APIRouter', 'from fastapi import APIRouter, Depends', 1)
57+
s = s.replace('from fastapi import APIRouter, Depends\n', 'from fastapi import APIRouter, Depends\nfrom app.core.security import get_current_user\nfrom app.db.models.auth import User\n', 1)
10158
s = s.replace('async def system_status():', 'async def system_status(current_user: User = Depends(get_current_user)):', 1)
102-
p.write_text(s, encoding='utf-8')
59+
p.write_text(s, encoding='utf-8')
10360
104-
# 3) Make full-stack deployment explicit and non-destructive
61+
# Deployment topology.
10562
p = Path('deploy/deploy.sh')
10663
s = p.read_text(encoding='utf-8')
107-
s = s.replace('deploy/docker-compose.prod.yml', 'deploy/docker-compose.full.yml')
108-
s = re.sub(r'^\s*cp\s+[^\n]*docker-compose\.full\.yml[^\n]*\n', '', s, flags=re.M)
64+
s = s.replace('docker-compose.prod.yml', 'docker-compose.full.yml')
65+
s = re.sub(r'^\s*cp\s+"?\$\{DEPLOY_DIR\}/docker-compose\.full\.yml"?\s+"?\$\{PROJECT_DIR\}/docker-compose\.prod\.yml"?\s*\n', '', s, flags=re.M)
66+
s = re.sub(r'^if \[\[ -f "\$\{DEPLOY_DIR\}/docker-compose\.full\.yml" \]\]; then\n\s*cp .*?\nfi\n', '', s, flags=re.M)
67+
if 'COMPOSE_FILE=' not in s:
68+
s = s.replace('DEPLOY_DIR="$(dirname "$0")"\n', 'DEPLOY_DIR="$(dirname "$0")"\nCOMPOSE_FILE="${DEPLOY_DIR}/docker-compose.full.yml"\n', 1)
69+
s = s.replace('docker compose -f docker-compose.full.yml', 'docker compose --project-directory "$PROJECT_DIR" -f "$COMPOSE_FILE"')
10970
p.write_text(s, encoding='utf-8')
110-
old = Path('deploy/docker-compose.prod.yml')
111-
full = Path('deploy/docker-compose.full.yml')
112-
if old.exists() and not full.exists():
113-
old.rename(full)
114-
elif old.exists() and full.exists():
115-
raise SystemExit('Both compose files exist; refusing to choose silently')
116-
elif not full.exists():
117-
raise SystemExit('Full compose file missing')
11871
119-
# 4) Behavioral tests against the actual source implementation
120-
test = Path('tests/security/test_medical_behavior.py')
121-
test.parent.mkdir(parents=True, exist_ok=True)
122-
test.write_text('''import re\nfrom pathlib import Path\n\ndef _load_ocr_function():\n source = Path("hf-space/app_core.py").read_text(encoding="utf-8")\n block = source[source.index("OCR_CORRECTIONS = {"):source.index("# ── Lazy-loaded modules")]\n ns = {"re": re}\n exec(block + "\\n" + source[source.index("def _auto_correct_ocr"):source.index("\\ndef _extract_ner")], ns)\n return ns["_auto_correct_ocr"]\n\ndef test_separator_preserved():\n assert _load_ocr_function()("ترامادول 0.5 mg")[0] == "ترامادول 0.5 mg"\n\ndef test_decimal_tokens_unchanged():\n fn = _load_ocr_function()\n for text in ("0.5", "1.25", "0.75", "٠٫٥", "١٫٢٥"):\n assert fn(text)[0] == text\n\ndef test_negations_unchanged():\n fn = _load_ocr_function()\n for text in ("لا", "ليس", "لم", "لن", "غير", "بدون", "لا يوجد سكري"):\n assert fn(text)[0] == text\n\ndef test_intended_ocr_correction():\n assert _load_ocr_function()("باراسيتبمول 500 mg")[0] == "باراسيتامول 500 mg"\n''', encoding='utf-8')
72+
# Rename alternate compose if needed.
73+
old = Path('deploy/docker-compose.prod.yml')
74+
new = Path('deploy/docker-compose.full.yml')
75+
if old.exists() and not new.exists():
76+
old.rename(new)
77+
if not new.exists():
78+
raise SystemExit('deploy/docker-compose.full.yml missing')
79+
80+
# Real behavioral regression tests: execute the extracted production function source.
81+
t = Path('tests/security/test_medical_behavior.py')
82+
t.parent.mkdir(parents=True, exist_ok=True)
83+
t.write_text('''import re\nfrom pathlib import Path\n\ndef _fn():\n s = Path("hf-space/app_core.py").read_text(encoding="utf-8")\n b = s[s.index("OCR_CORRECTIONS = {"):s.index("# ── Lazy-loaded modules")]\n f = s[s.index("def _auto_correct_ocr"):s.index("\\ndef _extract_ner")]\n ns = {"re": re}\n exec(b + "\\n" + f, ns)\n return ns["_auto_correct_ocr"]\n\ndef test_separator():\n assert _fn()("ترامادول 0.5 mg")[0] == "ترامادول 0.5 mg"\n\ndef test_decimals():\n fn = _fn()\n for x in ("0.5", "1.25", "0.75", "٠٫٥", "١٫٢٥"):\n assert fn(x)[0] == x\n\ndef test_negations():\n fn = _fn()\n for x in ("لا", "ليس", "لم", "لن", "غير", "بدون", "لا يوجد سكري"):\n assert fn(x)[0] == x\n\ndef test_intended_fix():\n assert _fn()("باراسيتبمول 500 mg")[0] == "باراسيتامول 500 mg"\n''', encoding='utf-8')
12384
PY
85+
12486
python -m pytest -q tests/security/test_medical_behavior.py
12587
grep -q '"ترامادول": "ترامادول"' hf-space/app_core.py
12688
! grep -q '"ترامادول ": "ترامادول"' hf-space/app_core.py
12789
grep -q 'Depends(get_current_user)' app/routers/admin.py
12890
test -f deploy/docker-compose.full.yml
12991
! test -f deploy/docker-compose.prod.yml
92+
grep -q 'docker-compose.full.yml' deploy/deploy.sh
13093
13194
- name: Commit applied fixes
13295
shell: bash
@@ -136,34 +99,24 @@ jobs:
13699
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
137100
git add hf-space/app_core.py app/routers/admin.py deploy/deploy.sh deploy/docker-compose.full.yml tests/security/test_medical_behavior.py
138101
git add -u deploy/docker-compose.prod.yml
139-
if git diff --cached --quiet; then
140-
echo 'Fixes already applied; nothing to commit.'
141-
else
142-
git commit -m 'fix: apply verified security hardening'
143-
git push origin HEAD:${{ github.event.pull_request.head.ref }}
144-
fi
102+
if git diff --cached --quiet; then exit 0; fi
103+
git commit -m 'fix: apply verified security hardening'
104+
git push origin HEAD:${{ github.event.pull_request.head.ref }}
145105
146106
lint:
147107
name: Code Quality
148108
runs-on: ubuntu-latest
149109
steps:
150110
- uses: actions/checkout@v4
151-
152111
- name: Set up Python
153112
uses: actions/setup-python@v5
154-
with:
155-
python-version: '3.11'
156-
113+
with: {python-version: '3.11'}
157114
- name: Install lint tools
158-
run: |
159-
pip install flake8 black isort mypy
160-
115+
run: pip install flake8 black isort mypy
161116
- name: Run flake8
162117
run: flake8 desktop/ hf-space/ app/ --count --statistics || true
163-
164118
- name: Run black check
165119
run: black --check desktop/ hf-space/ app/ || true
166-
167120
- name: Run isort check
168121
run: isort --check-only desktop/ hf-space/ app/ || true
169122

@@ -172,26 +125,18 @@ jobs:
172125
runs-on: ubuntu-latest
173126
steps:
174127
- uses: actions/checkout@v4
175-
176128
- name: Set up Python
177129
uses: actions/setup-python@v5
178-
with:
179-
python-version: '3.11'
180-
130+
with: {python-version: '3.11'}
181131
- name: Install system dependencies (Tesseract OCR)
182-
run: |
183-
sudo apt-get update -qq
184-
sudo apt-get install -y -qq tesseract-ocr tesseract-ocr-ara tesseract-ocr-eng libtesseract-dev 2>/dev/null || true
185-
132+
run: sudo apt-get update -qq && sudo apt-get install -y -qq tesseract-ocr tesseract-ocr-ara tesseract-ocr-eng libtesseract-dev 2>/dev/null || true
186133
- name: Install dependencies
187134
run: |
188135
pip install pytest pytest-cov
189136
pip install opencv-python Pillow numpy
190137
pip install "huggingface-hub<1.0.0" "pydantic<2.11.0"
191-
192138
- name: Run tests
193139
run: pytest tests/ -v --cov=. --cov-report=xml || echo "No tests yet"
194-
195140
- name: Upload coverage
196141
uses: codecov/codecov-action@v3
197142
if: always()

0 commit comments

Comments
 (0)