dexsec 0.7.1__py3-none-any.whl
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.
- dexsec/__init__.py +25 -0
- dexsec/__main__.py +4 -0
- dexsec/ai/__init__.py +0 -0
- dexsec/ai/advisor.py +344 -0
- dexsec/ai/apply.py +165 -0
- dexsec/ai/chaining.py +250 -0
- dexsec/ai/html_report.py +225 -0
- dexsec/ai/patcher.py +519 -0
- dexsec/ai/pdf.py +204 -0
- dexsec/ai/reporter.py +260 -0
- dexsec/ai/sarif.py +150 -0
- dexsec/cli.py +739 -0
- dexsec/config.py +159 -0
- dexsec/core/__init__.py +0 -0
- dexsec/core/baseline.py +111 -0
- dexsec/core/correlator.py +217 -0
- dexsec/core/notify.py +90 -0
- dexsec/core/output.py +81 -0
- dexsec/core/upgrade.py +145 -0
- dexsec/core/watcher.py +101 -0
- dexsec/dast/__init__.py +0 -0
- dexsec/dast/oast.py +108 -0
- dexsec/dast/openapi.py +297 -0
- dexsec/dast/probes.py +262 -0
- dexsec/dast/verifier.py +1054 -0
- dexsec/hooks.py +101 -0
- dexsec/manual/COVERAGE.md +124 -0
- dexsec/manual/PANDUAN.md +424 -0
- dexsec/models.py +171 -0
- dexsec/sast/__init__.py +0 -0
- dexsec/sast/access.py +151 -0
- dexsec/sast/ast_taint.py +464 -0
- dexsec/sast/config_scan.py +198 -0
- dexsec/sast/languages.py +457 -0
- dexsec/sast/php_calls.py +168 -0
- dexsec/sast/rules.py +507 -0
- dexsec/sast/scanner.py +565 -0
- dexsec-0.7.1.dist-info/METADATA +301 -0
- dexsec-0.7.1.dist-info/RECORD +43 -0
- dexsec-0.7.1.dist-info/WHEEL +5 -0
- dexsec-0.7.1.dist-info/entry_points.txt +2 -0
- dexsec-0.7.1.dist-info/licenses/LICENSE +29 -0
- dexsec-0.7.1.dist-info/top_level.txt +1 -0
dexsec/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""DEX-SEC — Inside-Out Hybrid Application Security Testing CLI.
|
|
2
|
+
|
|
3
|
+
Fase:
|
|
4
|
+
1. Inside Inspection -> route discovery + taint analysis (SAST)
|
|
5
|
+
2. Outside Verification-> safe, non-destructive probes against a live target
|
|
6
|
+
3. Correlation -> confirmed / potential / not reproducible
|
|
7
|
+
4. AI Remediation -> unified diff (LLM) or offline heuristic fallback
|
|
8
|
+
5. Attack Chain Analysis
|
|
9
|
+
6. Next Step Advisor
|
|
10
|
+
7. Reporting -> terminal summary + Markdown/JSON/HTML/PDF/SARIF
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
try: # installed package -> use the metadata (single source of truth)
|
|
16
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
__version__ = _version("dexsec")
|
|
20
|
+
except PackageNotFoundError: # running from a source checkout
|
|
21
|
+
__version__ = "0.7.0"
|
|
22
|
+
except ImportError: # pragma: no cover - Python < 3.8
|
|
23
|
+
__version__ = "0.7.0"
|
|
24
|
+
|
|
25
|
+
__all__ = ["__version__"]
|
dexsec/__main__.py
ADDED
dexsec/ai/__init__.py
ADDED
|
File without changes
|
dexsec/ai/advisor.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Next-step advisor.
|
|
2
|
+
|
|
3
|
+
Turns confirmed findings into an actionable, prioritised investigation plan:
|
|
4
|
+
what to check next, why it matters and which safe command to run.
|
|
5
|
+
|
|
6
|
+
Two engines are available:
|
|
7
|
+
* `offline` - deterministic heuristics, always available.
|
|
8
|
+
* `llm` - a language model reasons over the evidence and produces a
|
|
9
|
+
ranked plan (requires an API key, falls back to offline).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from ..models import CorrelatedFinding, VerificationStatus, VulnClass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class NextStep:
|
|
22
|
+
title: str
|
|
23
|
+
rationale: str
|
|
24
|
+
priority: str # HIGH | MEDIUM | LOW
|
|
25
|
+
commands: list[str] = field(default_factory=list)
|
|
26
|
+
relates_to: str = ""
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict:
|
|
29
|
+
return {
|
|
30
|
+
"title": self.title,
|
|
31
|
+
"rationale": self.rationale,
|
|
32
|
+
"priority": self.priority,
|
|
33
|
+
"commands": self.commands,
|
|
34
|
+
"relates_to": self.relates_to,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------- offline
|
|
39
|
+
HEURISTICS: dict[VulnClass, list[tuple[str, str, list[str]]]] = {
|
|
40
|
+
VulnClass.SQL_INJECTION: [
|
|
41
|
+
(
|
|
42
|
+
"Cek apakah sink yang sama ada di endpoint lain",
|
|
43
|
+
"Satu pola query bocor biasanya direplikasi di banyak endpoint. Jalankan DEX-SEC pada modul lain "
|
|
44
|
+
"atau grep assinatura query yang sama.",
|
|
45
|
+
["dexsec scan --path ./src --target http://localhost:8000"],
|
|
46
|
+
),
|
|
47
|
+
(
|
|
48
|
+
"Uji second-order injection",
|
|
49
|
+
"Data yang tersimpan (nama, komentar, profil) sering dipakai ulang di query lain tanpa sanitasi.",
|
|
50
|
+
["grep -rn \"SELECT\" ./src | head -50"],
|
|
51
|
+
),
|
|
52
|
+
(
|
|
53
|
+
"Petakan hak akses database aplikasi",
|
|
54
|
+
"Menilai dampak butuh tahu apakah akun aplikasi punya akses tulis pada tabel sensitif.",
|
|
55
|
+
["# periksa di environment dev, jangan di produksi"],
|
|
56
|
+
),
|
|
57
|
+
],
|
|
58
|
+
VulnClass.COMMAND_INJECTION: [
|
|
59
|
+
(
|
|
60
|
+
"Cari parameter lain yang masuk ke shell",
|
|
61
|
+
"Satu argumen bocor biasanya ada argumen lain di endpoint yang sama atau berbeda.",
|
|
62
|
+
["grep -rn \"subprocess\\|os.system\\|shell=True\" ./src"],
|
|
63
|
+
),
|
|
64
|
+
(
|
|
65
|
+
"Periksa hak akses service account",
|
|
66
|
+
"Dampak command injection bergantung pada user proses aplikasi dan akses jaringan internal.",
|
|
67
|
+
["# periksa deployment manifest / container user"],
|
|
68
|
+
),
|
|
69
|
+
],
|
|
70
|
+
VulnClass.PATH_TRAVERSAL: [
|
|
71
|
+
(
|
|
72
|
+
"Uji operasi tulis pada path yang sama",
|
|
73
|
+
"Jika file bisa dibaca di luar direktori, operasi tulis (upload, log, cache) mungkin juga bisa.",
|
|
74
|
+
["grep -rn \"open(.*['\\\"]w\\|writeFile\\|shutil.copy\\|os.remove\" ./src"],
|
|
75
|
+
),
|
|
76
|
+
(
|
|
77
|
+
"Verifikasi apakah path dinormalisasi di satu tempat saja",
|
|
78
|
+
"Perbaikan harus diterapkan di layer yang benar, bukan di setiap handler.",
|
|
79
|
+
["grep -rn \"basename\\|normpath\\|resolve(\" ./src"],
|
|
80
|
+
),
|
|
81
|
+
],
|
|
82
|
+
VulnClass.SSRF: [
|
|
83
|
+
(
|
|
84
|
+
"Uji jangkauan jaringan internal",
|
|
85
|
+
"SSRF yang terbukti sering juga menjangkau metadata cloud dan layanan internal.",
|
|
86
|
+
["# uji hanya di environment yang diizinkan (staging milik sendiri)"],
|
|
87
|
+
),
|
|
88
|
+
(
|
|
89
|
+
"Cek red-team URL parser & redirect service",
|
|
90
|
+
"Komponen seperti requests/httpx/urllib ada di banyak tempat; parser yang berbeda bisa memblokir URL berbeda.",
|
|
91
|
+
["grep -rn \"requests\\.\\|httpx\\.\\|urlopen\\|fetch(\" ./src"],
|
|
92
|
+
),
|
|
93
|
+
],
|
|
94
|
+
VulnClass.XXE: [
|
|
95
|
+
(
|
|
96
|
+
"Cek semua parser XML di project",
|
|
97
|
+
"Minidom, lxml, SAX dan ElementTree punya perilaku entity yang berbeda; semuanya perlu diaudit.",
|
|
98
|
+
["grep -rn \"minidom\\|etree\\|lxml\\|xml.sax\\|ElementTree\" ./src"],
|
|
99
|
+
),
|
|
100
|
+
],
|
|
101
|
+
VulnClass.IDOR: [
|
|
102
|
+
(
|
|
103
|
+
"Enumerasi objek milik user lain",
|
|
104
|
+
"IDOR yang terbukti biasanya berlaku untuk semua koleksi yang memakai pola query sama.",
|
|
105
|
+
["# gunakan dua akun uji milik sendiri"],
|
|
106
|
+
),
|
|
107
|
+
(
|
|
108
|
+
"Periksa endpoint tulis (PUT/PATCH/DELETE) dengan pola sama",
|
|
109
|
+
"Membaca objek milik orang lain biasanya lebih ringan, menulisnya biasanya lebih berbahaya.",
|
|
110
|
+
["dexsec routes --path ."],
|
|
111
|
+
),
|
|
112
|
+
],
|
|
113
|
+
VulnClass.OPEN_REDIRECT: [
|
|
114
|
+
(
|
|
115
|
+
"Periksa dampaknya pada alur OAuth/SSO",
|
|
116
|
+
"Open redirect pada callback OAuth,很多时候 berarti token bocor ke domain penyerang.",
|
|
117
|
+
["grep -rn \"redirect\\|RedirectResponse\\|return_to\\|next=\" ./src"],
|
|
118
|
+
),
|
|
119
|
+
],
|
|
120
|
+
VulnClass.TEMPLATE_INJECTION: [
|
|
121
|
+
(
|
|
122
|
+
"Cari semua pemakaian template dinamis",
|
|
123
|
+
"Satu template yang dirender dari input biasanya punya saudara di modul lain.",
|
|
124
|
+
["grep -rn \"render_template_string\\|from_string\\|Template(\" ./src"],
|
|
125
|
+
),
|
|
126
|
+
],
|
|
127
|
+
VulnClass.INSECURE_DESERIALIZATION: [
|
|
128
|
+
(
|
|
129
|
+
"Petakan sumber data tidak tepercaya",
|
|
130
|
+
"Deserialisasi hanya berbahaya bila inputnya bisa dikendalikan pengguna.",
|
|
131
|
+
["grep -rn \"pickle\\|yaml.load\\|marshal\\|jsonpickle\" ./src"],
|
|
132
|
+
),
|
|
133
|
+
],
|
|
134
|
+
VulnClass.MASS_ASSIGNMENT: [
|
|
135
|
+
(
|
|
136
|
+
"Cek field sensitif pada model",
|
|
137
|
+
"Cari field seperti role, is_admin, is_staff, balance, verified yang bisa di-set client.",
|
|
138
|
+
["grep -rn \"role\\|is_admin\\|is_staff\\|balance\\|verified\" ./models ./src"],
|
|
139
|
+
),
|
|
140
|
+
],
|
|
141
|
+
VulnClass.HARDCODE_SECRET: [
|
|
142
|
+
(
|
|
143
|
+
"Nilai dampak & rotasi kredensial",
|
|
144
|
+
"Kredensial yang bocor di repo harus dianggap sudah kompromi, bukan hanya dihapus.",
|
|
145
|
+
["git log --all -S \"<nilai_yang_terdeteksi>\" --oneline"],
|
|
146
|
+
),
|
|
147
|
+
],
|
|
148
|
+
VulnClass.EXPOSED_SECRET_FILE: [
|
|
149
|
+
(
|
|
150
|
+
"Bersihkan dari history git",
|
|
151
|
+
"Menghapus file di commit terbaru tidak menghapus jejaknya di history.",
|
|
152
|
+
["git filter-repo --path <file> --invert-paths"],
|
|
153
|
+
),
|
|
154
|
+
],
|
|
155
|
+
VulnClass.JWT_NONE_ALG: [
|
|
156
|
+
(
|
|
157
|
+
"Audit issuance token",
|
|
158
|
+
"Periksa apakah server ikut memverifikasi signature saat membuat token (bukan hanya saat memvalidasi).",
|
|
159
|
+
["grep -rn \"jwt.encode\\|jwt.decode\\|HS256\\|RS256\" ./src"],
|
|
160
|
+
),
|
|
161
|
+
],
|
|
162
|
+
VulnClass.PERMISSIVE_CORS: [
|
|
163
|
+
(
|
|
164
|
+
"Uji dampak dengan Origin acak",
|
|
165
|
+
"CORS yang memantulkan origin membuat situs mana pun bisa membaca response terotorisasi.",
|
|
166
|
+
["curl -i <target> -H \"Origin: https://attacker.example\""],
|
|
167
|
+
),
|
|
168
|
+
],
|
|
169
|
+
VulnClass.MISSING_SECURITY_HEADERS: [
|
|
170
|
+
(
|
|
171
|
+
"Terapkan header keamanan di reverse proxy",
|
|
172
|
+
"Header ini paling mudah dikontrol di nginx/Cloudflare/ALB tanpa menyentuh kode aplikasi.",
|
|
173
|
+
["# tambahkan add_header X-Content-Type-Options nosniff; dll"],
|
|
174
|
+
),
|
|
175
|
+
],
|
|
176
|
+
VulnClass.DEBUG_MODE: [
|
|
177
|
+
(
|
|
178
|
+
"Pastikan debug dimatikan di seluruh environment",
|
|
179
|
+
"Flag debug sering diaktifkan lewat variabel lingkungan di staging tapi tidak di produksi.",
|
|
180
|
+
["grep -rn \"DEBUG\\|debug=\" ./src ./.env* 2>/dev/null"],
|
|
181
|
+
),
|
|
182
|
+
],
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
GENERIC_STEPS = [
|
|
186
|
+
(
|
|
187
|
+
"Ulangi scan pada branch/PR berikutnya",
|
|
188
|
+
"Jalankan DEX-SEC di pre-commit hook atau CI agar regresi langsung terlihat.",
|
|
189
|
+
["dexsec scan --path . --target http://localhost:3000 --json dexsec.json"],
|
|
190
|
+
),
|
|
191
|
+
(
|
|
192
|
+
"Dokumentasikan temuan & tetapkan pemilik",
|
|
193
|
+
"Temuan tanpa owner akan terulang. Sertakan PoC curl dari laporan ke issue tracker.",
|
|
194
|
+
[],
|
|
195
|
+
),
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _commands_for(item: CorrelatedFinding) -> list[str]:
|
|
200
|
+
commands: list[str] = []
|
|
201
|
+
verification = item.verification
|
|
202
|
+
if verification and verification.safe_poc_curl:
|
|
203
|
+
commands.append(verification.safe_poc_curl)
|
|
204
|
+
return commands
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def build_next_steps(
|
|
208
|
+
correlated: list[CorrelatedFinding],
|
|
209
|
+
target_url: str = "",
|
|
210
|
+
api_key: Optional[str] = None,
|
|
211
|
+
provider: str = "openai",
|
|
212
|
+
model: str = "gpt-4o-mini",
|
|
213
|
+
) -> list[NextStep]:
|
|
214
|
+
"""Produce a prioritised follow-up plan for the confirmed findings."""
|
|
215
|
+
actionable = [
|
|
216
|
+
item for item in correlated
|
|
217
|
+
if item.status in (VerificationStatus.CONFIRMED_VULNERABLE, VerificationStatus.POTENTIAL)
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
steps: list[NextStep] = []
|
|
221
|
+
if api_key:
|
|
222
|
+
llm_steps = _llm_next_steps(actionable, target_url, api_key, provider, model)
|
|
223
|
+
if llm_steps:
|
|
224
|
+
return llm_steps
|
|
225
|
+
|
|
226
|
+
for item in actionable[:8]:
|
|
227
|
+
heuristics = HEURISTICS.get(item.finding.vuln_class, [])
|
|
228
|
+
for title, rationale, commands in heuristics[:2]:
|
|
229
|
+
steps.append(
|
|
230
|
+
NextStep(
|
|
231
|
+
title=title,
|
|
232
|
+
rationale=rationale,
|
|
233
|
+
priority="HIGH" if item.status == VerificationStatus.CONFIRMED_VULNERABLE else "MEDIUM",
|
|
234
|
+
commands=commands + _commands_for(item),
|
|
235
|
+
relates_to=item.title,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if not steps:
|
|
240
|
+
for title, rationale, commands in GENERIC_STEPS:
|
|
241
|
+
steps.append(NextStep(title=title, rationale=rationale, priority="LOW", commands=commands))
|
|
242
|
+
|
|
243
|
+
priority_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
|
|
244
|
+
steps.sort(key=lambda s: priority_order.get(s.priority, 3))
|
|
245
|
+
return steps
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ------------------------------------------------------------------- llm
|
|
249
|
+
LLM_SYSTEM_PROMPT = (
|
|
250
|
+
"You are a senior application security consultant. You are given confirmed findings of an "
|
|
251
|
+
"application security test. Produce a concise, prioritised plan of what to investigate NEXT, "
|
|
252
|
+
"so the tester can assess the real impact and chain the findings. "
|
|
253
|
+
"Rules: only use safe, read-only, non-destructive actions that the tester is authorised to run "
|
|
254
|
+
"on their own systems. Never suggest destructive payloads, data exfiltration of real user data, "
|
|
255
|
+
"or anything that would damage the target. "
|
|
256
|
+
"Output JSON: {\"steps\": [{\"title\": str, \"rationale\": str, \"priority\": \"HIGH|MEDIUM|LOW\", "
|
|
257
|
+
"\"commands\": [str]}]}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _llm_next_steps(
|
|
262
|
+
correlated: list[CorrelatedFinding],
|
|
263
|
+
target_url: str,
|
|
264
|
+
api_key: str,
|
|
265
|
+
provider: str,
|
|
266
|
+
model: str,
|
|
267
|
+
) -> list[NextStep]:
|
|
268
|
+
import json
|
|
269
|
+
|
|
270
|
+
summary = []
|
|
271
|
+
for item in correlated[:12]:
|
|
272
|
+
summary.append(
|
|
273
|
+
{
|
|
274
|
+
"vulnerability": item.finding.vuln_class.value,
|
|
275
|
+
"severity": item.final_severity.value,
|
|
276
|
+
"status": item.status.value,
|
|
277
|
+
"location": f"{item.finding.file}:{item.finding.line}",
|
|
278
|
+
"route": item.finding.route.path if item.finding.route else None,
|
|
279
|
+
"evidence": item.verification.evidence if item.verification else "",
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
prompt = (
|
|
283
|
+
f"Target: {target_url}\n"
|
|
284
|
+
f"Confirmed findings:\n{json.dumps(summary, indent=2, default=str)}\n\n"
|
|
285
|
+
"Give the next investigation steps."
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
raw = ""
|
|
289
|
+
try:
|
|
290
|
+
if provider == "openai":
|
|
291
|
+
from openai import OpenAI # type: ignore
|
|
292
|
+
|
|
293
|
+
client = OpenAI(api_key=api_key)
|
|
294
|
+
resp = client.chat.completions.create(
|
|
295
|
+
model=model,
|
|
296
|
+
messages=[
|
|
297
|
+
{"role": "system", "content": LLM_SYSTEM_PROMPT},
|
|
298
|
+
{"role": "user", "content": prompt},
|
|
299
|
+
],
|
|
300
|
+
temperature=0.2,
|
|
301
|
+
)
|
|
302
|
+
raw = resp.choices[0].message.content or ""
|
|
303
|
+
elif provider == "anthropic":
|
|
304
|
+
import anthropic # type: ignore
|
|
305
|
+
|
|
306
|
+
client = anthropic.Anthropic(api_key=api_key)
|
|
307
|
+
msg = client.messages.create(
|
|
308
|
+
model=model,
|
|
309
|
+
max_tokens=2000,
|
|
310
|
+
system=LLM_SYSTEM_PROMPT,
|
|
311
|
+
messages=[{"role": "user", "content": prompt}],
|
|
312
|
+
)
|
|
313
|
+
raw = "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
|
|
314
|
+
elif provider == "gemini":
|
|
315
|
+
import google.generativeai as genai # type: ignore
|
|
316
|
+
|
|
317
|
+
genai.configure(api_key=api_key)
|
|
318
|
+
raw = genai.GenerativeModel(model).generate_content([LLM_SYSTEM_PROMPT, prompt]).text or ""
|
|
319
|
+
except Exception:
|
|
320
|
+
return []
|
|
321
|
+
|
|
322
|
+
if not raw:
|
|
323
|
+
return []
|
|
324
|
+
start = raw.find("{")
|
|
325
|
+
end = raw.rfind("}")
|
|
326
|
+
if start < 0 or end < start:
|
|
327
|
+
return []
|
|
328
|
+
try:
|
|
329
|
+
data = json.loads(raw[start: end + 1])
|
|
330
|
+
except json.JSONDecodeError:
|
|
331
|
+
return []
|
|
332
|
+
|
|
333
|
+
steps: list[NextStep] = []
|
|
334
|
+
for entry in data.get("steps", []):
|
|
335
|
+
steps.append(
|
|
336
|
+
NextStep(
|
|
337
|
+
title=str(entry.get("title", "")).strip(),
|
|
338
|
+
rationale=str(entry.get("rationale", "")).strip(),
|
|
339
|
+
priority=str(entry.get("priority", "MEDIUM")).upper(),
|
|
340
|
+
commands=[str(c) for c in entry.get("commands", [])][:5],
|
|
341
|
+
relates_to="LLM analysis",
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
return steps
|
dexsec/ai/apply.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Minimal unified diff applier.
|
|
2
|
+
|
|
3
|
+
DEX-SEC can fix code even when the project is not a git repository, so the
|
|
4
|
+
patch is applied with a small self-contained implementation of the unified
|
|
5
|
+
diff format instead of shelling out to ``git apply``.
|
|
6
|
+
|
|
7
|
+
Only the subset produced by DEX-SEC is supported:
|
|
8
|
+
|
|
9
|
+
--- a/path
|
|
10
|
+
+++ b/path
|
|
11
|
+
@@ -start,count +start,count @@
|
|
12
|
+
context
|
|
13
|
+
-removed
|
|
14
|
+
+added
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Hunk:
|
|
28
|
+
old_start: int
|
|
29
|
+
old_count: int
|
|
30
|
+
new_start: int
|
|
31
|
+
new_count: int
|
|
32
|
+
lines: list[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class FilePatch:
|
|
37
|
+
old_path: str
|
|
38
|
+
new_path: str
|
|
39
|
+
hunks: list[Hunk] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ApplyResult:
|
|
44
|
+
applied: list[str] = field(default_factory=list)
|
|
45
|
+
failed: list[tuple[str, str]] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def ok(self) -> bool:
|
|
49
|
+
return not self.failed
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_diff(diff: str) -> list[FilePatch]:
|
|
53
|
+
"""Parse a unified diff into file patches."""
|
|
54
|
+
patches: list[FilePatch] = []
|
|
55
|
+
current: FilePatch | None = None
|
|
56
|
+
hunk: Hunk | None = None
|
|
57
|
+
|
|
58
|
+
for raw in diff.splitlines():
|
|
59
|
+
if raw.startswith("--- "):
|
|
60
|
+
current = FilePatch(old_path=_strip_prefix(raw[4:]), new_path="")
|
|
61
|
+
patches.append(current)
|
|
62
|
+
hunk = None
|
|
63
|
+
continue
|
|
64
|
+
if raw.startswith("+++ "):
|
|
65
|
+
if current is None:
|
|
66
|
+
current = FilePatch(old_path="", new_path="")
|
|
67
|
+
patches.append(current)
|
|
68
|
+
current.new_path = _strip_prefix(raw[4:])
|
|
69
|
+
hunk = None
|
|
70
|
+
continue
|
|
71
|
+
if raw.startswith("@@"):
|
|
72
|
+
match = HUNK_RE.match(raw)
|
|
73
|
+
if not match or current is None:
|
|
74
|
+
hunk = None
|
|
75
|
+
continue
|
|
76
|
+
hunk = Hunk(
|
|
77
|
+
old_start=int(match.group(1)),
|
|
78
|
+
old_count=int(match.group(2) or 1),
|
|
79
|
+
new_start=int(match.group(3)),
|
|
80
|
+
new_count=int(match.group(4) or 1),
|
|
81
|
+
)
|
|
82
|
+
current.hunks.append(hunk)
|
|
83
|
+
continue
|
|
84
|
+
if hunk is None:
|
|
85
|
+
continue
|
|
86
|
+
if raw.startswith((" ", "-", "+")):
|
|
87
|
+
hunk.lines.append(raw)
|
|
88
|
+
elif raw == "":
|
|
89
|
+
hunk.lines.append(" ")
|
|
90
|
+
elif raw.startswith("\\"):
|
|
91
|
+
continue # ""
|
|
92
|
+
return patches
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _strip_prefix(path: str) -> str:
|
|
96
|
+
path = path.strip()
|
|
97
|
+
if path.startswith(("a/", "b/")):
|
|
98
|
+
return path[2:]
|
|
99
|
+
return path
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _read_lines(path: str) -> list[str]:
|
|
103
|
+
with open(path, "r", encoding="utf-8", errors="replace", newline="") as fh:
|
|
104
|
+
text = fh.read()
|
|
105
|
+
return text.splitlines()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def apply_diff(diff: str, root: str, dry_run: bool = False) -> ApplyResult:
|
|
109
|
+
"""Apply a unified diff to the files below *root*."""
|
|
110
|
+
result = ApplyResult()
|
|
111
|
+
for patch in parse_diff(diff):
|
|
112
|
+
target = patch.new_path or patch.old_path
|
|
113
|
+
if not target:
|
|
114
|
+
continue
|
|
115
|
+
absolute = target if os.path.isabs(target) else os.path.join(root, target)
|
|
116
|
+
if not os.path.isfile(absolute):
|
|
117
|
+
result.failed.append((target, "file not found"))
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
original = _read_lines(absolute)
|
|
121
|
+
except OSError as exc:
|
|
122
|
+
result.failed.append((target, str(exc)))
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
patched, error = _apply_hunks(original, patch.hunks)
|
|
126
|
+
if error:
|
|
127
|
+
result.failed.append((target, error))
|
|
128
|
+
continue
|
|
129
|
+
if not dry_run:
|
|
130
|
+
try:
|
|
131
|
+
with open(absolute, "w", encoding="utf-8", newline="") as fh:
|
|
132
|
+
fh.write("\n".join(patched) + "\n")
|
|
133
|
+
except OSError as exc:
|
|
134
|
+
result.failed.append((target, str(exc)))
|
|
135
|
+
continue
|
|
136
|
+
result.applied.append(target)
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _apply_hunks(original: list[str], hunks: list[Hunk]) -> tuple[list[str], str]:
|
|
141
|
+
result: list[str] = []
|
|
142
|
+
cursor = 0 # index into the original file
|
|
143
|
+
for hunk in hunks:
|
|
144
|
+
start = max(hunk.old_start - 1, 0)
|
|
145
|
+
if start < cursor:
|
|
146
|
+
return original, "overlapping hunks"
|
|
147
|
+
result.extend(original[cursor:start])
|
|
148
|
+
cursor = start
|
|
149
|
+
for line in hunk.lines:
|
|
150
|
+
if not line:
|
|
151
|
+
continue
|
|
152
|
+
marker, text = line[0], line[1:]
|
|
153
|
+
if marker == " ":
|
|
154
|
+
if cursor >= len(original) or original[cursor] != text:
|
|
155
|
+
return original, f"context mismatch near line {cursor + 1}"
|
|
156
|
+
result.append(original[cursor])
|
|
157
|
+
cursor += 1
|
|
158
|
+
elif marker == "-":
|
|
159
|
+
if cursor >= len(original) or original[cursor] != text:
|
|
160
|
+
return original, f"removal mismatch near line {cursor + 1}"
|
|
161
|
+
cursor += 1
|
|
162
|
+
elif marker == "+":
|
|
163
|
+
result.append(text)
|
|
164
|
+
result.extend(original[cursor:])
|
|
165
|
+
return result, ""
|