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 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
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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, ""