ptpasstime 0.0.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.
ptpasstime/__init__.py ADDED
@@ -0,0 +1 @@
1
+
ptpasstime/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
@@ -0,0 +1,647 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ Copyright (c) 2026 Penterep Security s.r.o.
4
+
5
+ ptpasstime - password comparison timing attack tester
6
+
7
+ ptpasstime is free software: you can redistribute it and/or modify
8
+ it under the terms of the GNU General Public License as published by
9
+ the Free Software Foundation, either version 3 of the License, or
10
+ (at your option) any later version.
11
+
12
+ ptpasstime is distributed in the hope that it will be useful,
13
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ GNU General Public License for more details.
16
+
17
+ You should have received a copy of the GNU General Public License
18
+ along with ptpasstime. If not, see <https://www.gnu.org/licenses/>.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import base64
25
+ import binascii
26
+ import statistics
27
+ import string
28
+ import sys
29
+ import time
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+ from urllib.parse import parse_qsl, urlparse, urlunparse
33
+
34
+ import requests
35
+ from scipy.stats import mannwhitneyu
36
+
37
+ sys.path.append(__file__.rsplit("/", 1)[0])
38
+
39
+ from _version import __version__
40
+ from ptlibs import ptjsonlib, ptprinthelper, ptmisclib, ptnethelper
41
+ from ptlibs.ptprinthelper import ptprint
42
+
43
+ DEFAULT_PLACEHOLDER = "INJECT"
44
+ DEFAULT_WRONG_CHAR = "X"
45
+ DEFAULT_CHARSET = string.ascii_lowercase + string.digits + string.ascii_uppercase
46
+
47
+
48
+ @dataclass
49
+ class RequestSpec:
50
+ method: str
51
+ url: str
52
+ headers: dict[str, str]
53
+ body_template: str | None
54
+
55
+
56
+ @dataclass
57
+ class AttemptResult:
58
+ attempt_type: str
59
+ password: str
60
+ median_ms: float
61
+ samples_ms: list[float]
62
+
63
+
64
+ class PtPassTime:
65
+ def __init__(self, args: argparse.Namespace):
66
+ self.args = args
67
+ self.ptjsonlib = ptjsonlib.PtJsonLib()
68
+ self.spec = self._build_request_spec()
69
+
70
+ def run(self) -> None:
71
+ if self.args.brute_force:
72
+ self._run_brute_force()
73
+ else:
74
+ self._run_detection()
75
+
76
+ def _build_request_spec(self) -> RequestSpec:
77
+ if self.args.request_file:
78
+ return self._spec_from_request_file(self.args.request_file)
79
+
80
+ if not self.args.url:
81
+ raise ValueError("URL is required when --request-file is not used.")
82
+ if self.args.data is None:
83
+ raise ValueError("POST data (-d) is required when --request-file is not used.")
84
+
85
+ return RequestSpec(
86
+ method="POST",
87
+ url=self.args.url,
88
+ headers=self.args.headers.copy(),
89
+ body_template=self.args.data,
90
+ )
91
+
92
+ def _spec_from_request_file(self, request_file_or_b64: str) -> RequestSpec:
93
+ raw = self._load_request_source(request_file_or_b64)
94
+ text = raw.decode("utf-8", errors="replace").replace("\r\n", "\n")
95
+ lines = text.split("\n")
96
+ if not lines or not lines[0].strip():
97
+ raise ValueError("Invalid request input: missing request line.")
98
+
99
+ first = lines[0].strip()
100
+ parts = first.split()
101
+ if len(parts) < 2:
102
+ raise ValueError("Invalid request line, expected: METHOD PATH HTTP/x.y")
103
+
104
+ method = parts[0].upper()
105
+ target = parts[1]
106
+ headers: dict[str, str] = {}
107
+ body_idx = len(lines)
108
+ for i in range(1, len(lines)):
109
+ line = lines[i]
110
+ if line == "":
111
+ body_idx = i + 1
112
+ break
113
+ if ":" not in line:
114
+ continue
115
+ key, value = line.split(":", 1)
116
+ headers[key.strip()] = value.strip()
117
+
118
+ body_text = "\n".join(lines[body_idx:]) if body_idx < len(lines) else ""
119
+ url = self._build_url_from_target(target, headers)
120
+ merged_headers = self.args.headers.copy()
121
+ merged_headers.update(headers)
122
+
123
+ return RequestSpec(
124
+ method=method,
125
+ url=url,
126
+ headers=merged_headers,
127
+ body_template=body_text if body_text else None,
128
+ )
129
+
130
+ def _load_request_source(self, value: str) -> bytes:
131
+ path = Path(value)
132
+ if path.is_file():
133
+ return path.read_bytes()
134
+ try:
135
+ return base64.b64decode(value, validate=True)
136
+ except (binascii.Error, ValueError) as exc:
137
+ raise ValueError(
138
+ "Invalid --request-file value. Use an existing file path or base64 encoded request."
139
+ ) from exc
140
+
141
+ def _build_url_from_target(self, target: str, headers: dict[str, str]) -> str:
142
+ if target.startswith(("http://", "https://")):
143
+ return target
144
+
145
+ host = headers.get("Host", "").strip()
146
+ if not host:
147
+ raise ValueError("Raw request contains relative path but missing Host header.")
148
+ base = self.args.url if self.args.url else f"http://{host}"
149
+ parsed = urlparse(base if "://" in base else f"http://{base}")
150
+ path = target if target.startswith("/") else f"/{target}"
151
+ return urlunparse((parsed.scheme, host, path, "", "", ""))
152
+
153
+ def _build_body(self, password: str) -> str | bytes | dict[str, str] | None:
154
+ if self.spec.body_template is None:
155
+ return None
156
+ if self.args.placeholder not in self.spec.body_template:
157
+ raise ValueError(
158
+ f"Placeholder '{self.args.placeholder}' not found in request body. "
159
+ "Use -d 'username=admin&password=INJECT' or include INJECT in --request-file."
160
+ )
161
+ body = self.spec.body_template.replace(self.args.placeholder, password)
162
+ content_type = self.spec.headers.get("Content-Type", "").lower()
163
+ if "application/x-www-form-urlencoded" in content_type or (
164
+ not content_type and "=" in body and not body.strip().startswith("{")
165
+ ):
166
+ return dict(parse_qsl(body, keep_blank_values=True))
167
+ return body.encode("utf-8")
168
+
169
+ def _send_timed_request(self, password: str) -> float:
170
+ start = time.perf_counter()
171
+ requests.request(
172
+ method=self.spec.method,
173
+ url=self.spec.url,
174
+ headers=self.spec.headers,
175
+ data=self._build_body(password),
176
+ timeout=self.args.timeout,
177
+ proxies=self.args.proxy,
178
+ allow_redirects=self.args.redirects,
179
+ verify=False,
180
+ )
181
+ return (time.perf_counter() - start) * 1000
182
+
183
+ def _measure_password(self, attempt_type: str, password: str, repeat: int | None = None) -> AttemptResult:
184
+ n = repeat if repeat is not None else self.args.repeat
185
+ samples: list[float] = []
186
+ errors = 0
187
+ for _ in range(n):
188
+ try:
189
+ samples.append(self._send_timed_request(password))
190
+ except requests.RequestException:
191
+ errors += 1
192
+
193
+ if not samples:
194
+ raise RuntimeError(f"All {n} attempts failed for type '{attempt_type}'.")
195
+
196
+ if errors and not self.args.json:
197
+ ptprint(
198
+ f"{attempt_type}: {errors}/{n} requests failed",
199
+ "WARNING",
200
+ condition=True,
201
+ )
202
+
203
+ return AttemptResult(
204
+ attempt_type=attempt_type,
205
+ password=password,
206
+ median_ms=statistics.median(samples),
207
+ samples_ms=samples,
208
+ )
209
+
210
+ def _is_server_vulnerable(self) -> bool:
211
+ """Quick pre-check with up to 5 repeats. Returns True if timing side-channel detected."""
212
+ quick_n = min(self.args.repeat, 5)
213
+ passwords = self._build_test_passwords()
214
+ baseline = self._measure_password("all_wrong", passwords["all_wrong"], repeat=quick_n)
215
+ last = self._measure_password("last_wrong", passwords["last_wrong"], repeat=quick_n)
216
+ vuln, _ = self._is_vulnerable(baseline, last)
217
+ return vuln
218
+
219
+ def _build_test_passwords(self) -> dict[str, str]:
220
+ password = self.args.password
221
+ length = len(password)
222
+ wrong = self.args.wrong_char
223
+
224
+ if length == 0:
225
+ raise ValueError("Password (-p) must not be empty.")
226
+
227
+ all_wrong = wrong * length
228
+ first_wrong = wrong + password[1:] if length > 1 else wrong
229
+ last_wrong = password[:-1] + wrong if length > 1 else wrong
230
+
231
+ return {
232
+ "all_wrong": all_wrong,
233
+ "first_wrong": first_wrong,
234
+ "last_wrong": last_wrong,
235
+ }
236
+
237
+ def _threshold_ms(self, baseline_ms: float) -> float:
238
+ percent_threshold = baseline_ms * (self.args.threshold_percent / 100)
239
+ return max(percent_threshold, self.args.threshold_ms)
240
+
241
+ def _is_vulnerable(self, baseline: AttemptResult, candidate: AttemptResult) -> tuple[bool, float]:
242
+ """Return (is_vulnerable, p_value).
243
+
244
+ Uses Mann-Whitney U test for statistical significance combined with a
245
+ minimum median-delta guard, so that pure network jitter does not
246
+ trigger false positives on low-latency connections.
247
+ """
248
+ delta = candidate.median_ms - baseline.median_ms
249
+ threshold = self._threshold_ms(baseline.median_ms)
250
+ if delta <= threshold:
251
+ return False, 1.0
252
+
253
+ if len(baseline.samples_ms) < 3 or len(candidate.samples_ms) < 3:
254
+ return True, 0.0
255
+
256
+ try:
257
+ _, p_value = mannwhitneyu(
258
+ candidate.samples_ms,
259
+ baseline.samples_ms,
260
+ alternative="greater",
261
+ )
262
+ except ValueError:
263
+ return True, 0.0
264
+
265
+ p_value = float(p_value)
266
+ return p_value < self.args.p_value, p_value
267
+
268
+ LABELS: dict[str, str] = {
269
+ "all_wrong": "wrong password",
270
+ "first_wrong": "first char off",
271
+ "last_wrong": "last char off",
272
+ }
273
+
274
+ _C_RESET = "\033[0m"
275
+ _C_CYAN = "\033[96m"
276
+ _C_RED = "\033[31m"
277
+ _C_GREEN = "\033[92m"
278
+ _C_YELLOW = "\033[93m"
279
+ _C_GREY = "\033[90m"
280
+
281
+ def _print_separator(self, width: int = 68) -> None:
282
+ print(f" {'─' * width}")
283
+
284
+ def _run_detection(self) -> None:
285
+ passwords = self._build_test_passwords()
286
+ results: list[AttemptResult] = []
287
+ not_json = not self.args.json
288
+ W = 18
289
+
290
+ if not_json:
291
+ ptprint(f"Target : {self.spec.method} {self.spec.url}", "TITLE", condition=True, colortext=True)
292
+ if self.args.verbose:
293
+ ptprint(
294
+ f"Password : {len(self.args.password)} chars | "
295
+ f"repeats: {self.args.repeat} | "
296
+ f"threshold: {self.args.threshold_percent}% | "
297
+ f"p ≤ {self.args.p_value}",
298
+ "ADDITIONS", condition=True, indent=4, colortext=True,
299
+ )
300
+ ptprint(" ", "ADDITIONS", condition=True)
301
+ ptprint(f"{'Scenario':<{W}} {'Avg (ms)':>9} Samples", "ADDITIONS", condition=True, indent=4, colortext=True)
302
+ ptprint("─" * 68, "ADDITIONS", condition=True, indent=4, colortext=True)
303
+
304
+ for attempt_type, candidate in passwords.items():
305
+ result = self._measure_password(attempt_type, candidate)
306
+ results.append(result)
307
+ if not_json and self.args.verbose:
308
+ samples_str = " ".join(f"{s:.1f}" for s in result.samples_ms)
309
+ label = self.LABELS[attempt_type]
310
+ ptprint(f"{label:<{W}} {result.median_ms:>9.2f} {samples_str}", "ADDITIONS", condition=True, indent=4, colortext=True)
311
+
312
+ baseline = next(r for r in results if r.attempt_type == "all_wrong")
313
+ first = next(r for r in results if r.attempt_type == "first_wrong")
314
+ last = next(r for r in results if r.attempt_type == "last_wrong")
315
+
316
+ threshold = self._threshold_ms(baseline.median_ms)
317
+ first_vuln, first_p = self._is_vulnerable(baseline, first)
318
+ last_vuln, last_p = self._is_vulnerable(baseline, last)
319
+ vulnerable = first_vuln or last_vuln
320
+
321
+ if not_json:
322
+ print()
323
+ print(f" {'Scenario':<{W}} {'Δ (ms)':>9} {'p-value':>8} Result")
324
+ self._print_separator(50)
325
+
326
+ for result, vuln, p in (
327
+ (first, first_vuln, first_p),
328
+ (last, last_vuln, last_p),
329
+ ):
330
+ delta = result.median_ms - baseline.median_ms
331
+ label = self.LABELS[result.attempt_type]
332
+ color = self._C_RED if vuln else self._C_GREEN
333
+ marker = "[✗] VULNERABLE" if vuln else "[✓] OK"
334
+ print(f" {label:<{W}} {delta:>+9.2f} {p:>8.3f} {color}{marker}{self._C_RESET}")
335
+
336
+ print()
337
+ if vulnerable:
338
+ ptprint(
339
+ "RESULT: Target is VULNERABLE to a password timing side-channel attack.",
340
+ "VULN", condition=True, colortext=True,
341
+ )
342
+ else:
343
+ ptprint(
344
+ "RESULT: No timing side-channel detected on this target.",
345
+ "OK", condition=True, colortext=True,
346
+ )
347
+
348
+ self._emit_json_detection(results, vulnerable, first_vuln, last_vuln, threshold, first_p, last_p)
349
+
350
+ def _pick_wrong_char(self, reference: str) -> str:
351
+ for candidate in (DEFAULT_WRONG_CHAR, "#", "!", "0", "z"):
352
+ if candidate not in reference:
353
+ return candidate
354
+ return "Q"
355
+
356
+ def _build_bruteforce_candidate(self, prefix: str, char: str, total_len: int) -> str:
357
+ wrong = self._pick_wrong_char(self.args.password)
358
+ suffix_len = total_len - len(prefix) - 1
359
+ if suffix_len < 0:
360
+ raise ValueError("Brute-force prefix longer than target password length.")
361
+ return prefix + char + (wrong * suffix_len)
362
+
363
+ def _run_brute_force(self) -> None:
364
+ total_len = len(self.args.password)
365
+ known = ""
366
+ discovered: list[tuple[int, str, float]] = []
367
+ not_json = not self.args.json
368
+ W = 8
369
+
370
+ if not_json:
371
+ ptprint(
372
+ f"{'Target':<{W}}: {self.spec.method} {self.spec.url}",
373
+ "TITLE", condition=True, colortext=True,
374
+ )
375
+ ptprint("", "INFO", condition=True)
376
+ ptprint(
377
+ f"{'Mode':<{W}} brute-force",
378
+ condition=True, indent=4,
379
+ )
380
+ ptprint(
381
+ f"{'Password':<{W}} {total_len} chars | "
382
+ f"charset: {len(self.args.charset)} | "
383
+ f"repeats: {self.args.repeat}", condition=True, indent=4,
384
+ )
385
+ ptprint("", "INFO", condition=True,)
386
+ ptprint("Running vulnerability pre-check...", "INFO", condition=True)
387
+
388
+ server_vulnerable = self._is_server_vulnerable()
389
+
390
+ if not server_vulnerable:
391
+ if not_json:
392
+ ptprint("", "TEXT", condition=True)
393
+ ptprint(
394
+ "WARNING: Target does not appear vulnerable to timing attacks.",
395
+ "WARNING", condition=True, colortext=True,
396
+ )
397
+ ptprint(
398
+ "Brute-force results may be unreliable.",
399
+ "WARNING", condition=True, indent=4, colortext=True,
400
+ )
401
+ ptprint("", "TEXT", condition=True)
402
+ try:
403
+ answer = input("Continue anyway? [y/N] ").strip().lower()
404
+ except (EOFError, KeyboardInterrupt):
405
+ answer = "n"
406
+ if answer != "y":
407
+ ptprint("Aborted.", "INFO", condition=True)
408
+ return
409
+ ptprint("", "TEXT", condition=True)
410
+ else:
411
+ return
412
+
413
+ if not_json:
414
+ ptprint("Recovering password...", "INFO", condition=True)
415
+
416
+ for position in range(total_len):
417
+ best_char = ""
418
+ best_median = -1.0
419
+ char_results: list[tuple[str, float]] = []
420
+
421
+ for char in self.args.charset:
422
+ candidate = self._build_bruteforce_candidate(known, char, total_len)
423
+ result = self._measure_password(f"pos{position}_{char}", candidate)
424
+ char_results.append((char, result.median_ms))
425
+ if result.median_ms > best_median:
426
+ best_median = result.median_ms
427
+ best_char = char
428
+
429
+ known += best_char
430
+ discovered.append((position, best_char, best_median))
431
+
432
+ if not_json:
433
+ pos_label = f"Position {position + 1:>{len(str(total_len))}}/{total_len}"
434
+ line = f"{pos_label} → '{best_char}' {best_median:.2f} ms"
435
+ if self.args.verbose:
436
+ top = sorted(char_results, key=lambda item: item[1], reverse=True)[:5]
437
+ top_str = " ".join(f"{c}:{t:.1f}" for c, t in top)
438
+ line += f" top: {top_str}"
439
+ ptprint(line, "ADDITIONS", condition=True, indent=4, colortext=True)
440
+
441
+ matches = known == self.args.password
442
+ if not_json:
443
+ ptprint("", "TEXT", condition=True)
444
+ ptprint(f"Recovered password: {known}", "TITLE", condition=True, colortext=True)
445
+ ptprint(
446
+ f"Matches supplied password (-p): {'YES' if matches else 'NO'}",
447
+ "OK" if matches else "WARNING",
448
+ condition=True, colortext=True,
449
+ )
450
+
451
+ self._emit_json_bruteforce(known, discovered, matches)
452
+
453
+ def _emit_json_detection(
454
+ self,
455
+ results: list[AttemptResult],
456
+ vulnerable: bool,
457
+ first_vuln: bool,
458
+ last_vuln: bool,
459
+ threshold: float,
460
+ first_p: float,
461
+ last_p: float,
462
+ ) -> None:
463
+ if vulnerable:
464
+ self.ptjsonlib.add_vulnerability("PTV-WEB-AUTH-TIMING")
465
+
466
+ if not vulnerable:
467
+ return
468
+
469
+ self.ptjsonlib.add_vulnerability("PTV-WEB-AUTH-TIMING")
470
+ self.ptjsonlib.add_properties(
471
+ {
472
+ "mode": "detection",
473
+ "method": self.spec.method,
474
+ "url": self.spec.url,
475
+ "passwordLength": len(self.args.password),
476
+ "repeatCount": self.args.repeat,
477
+ "thresholdPercent": self.args.threshold_percent,
478
+ "thresholdMs": self.args.threshold_ms,
479
+ "pValueThreshold": self.args.p_value,
480
+ "effectiveThresholdMs": round(threshold, 3),
481
+ "firstWrongVulnerable": first_vuln,
482
+ "firstWrongPValue": round(first_p, 4),
483
+ "lastWrongVulnerable": last_vuln,
484
+ "lastWrongPValue": round(last_p, 4),
485
+ "attempts": [
486
+ {
487
+ "type": result.attempt_type,
488
+ "passwordSample": result.password,
489
+ "medianMs": round(result.median_ms, 3),
490
+ "samplesMs": [round(sample, 3) for sample in result.samples_ms],
491
+ }
492
+ for result in results
493
+ ],
494
+ }
495
+ )
496
+ self.ptjsonlib.set_status("finished")
497
+ ptprint(self.ptjsonlib.get_result_json(), "", self.args.json)
498
+
499
+ def _emit_json_bruteforce(
500
+ self,
501
+ recovered: str,
502
+ discovered: list[tuple[int, str, float]],
503
+ matches: bool,
504
+ ) -> None:
505
+ if matches:
506
+ self.ptjsonlib.add_vulnerability("PTV-WEB-AUTH-TIMING")
507
+
508
+ self.ptjsonlib.add_properties(
509
+ {
510
+ "mode": "brute-force",
511
+ "method": self.spec.method,
512
+ "url": self.spec.url,
513
+ "recoveredPassword": recovered,
514
+ "matchesSuppliedPassword": matches,
515
+ "positions": [
516
+ {
517
+ "position": position + 1,
518
+ "character": char,
519
+ "medianMs": round(median_ms, 3),
520
+ }
521
+ for position, char, median_ms in discovered
522
+ ],
523
+ }
524
+ )
525
+ self.ptjsonlib.set_status("finished")
526
+ ptprint(self.ptjsonlib.get_result_json(), "", self.args.json)
527
+
528
+
529
+ def get_help():
530
+ return [
531
+ {
532
+ "description": [
533
+ "Test whether a login endpoint compares passwords in constant time.",
534
+ "Detects timing side-channels exploitable for password recovery.",
535
+ ]
536
+ },
537
+ {"usage": ["ptpasstime <options>"]},
538
+ {
539
+ "usage_example": [
540
+ "ptpasstime -u http://127.0.0.1:5000/login/vulnerable "
541
+ "-d 'username=admin&password=INJECT' -p correctPassword -n 15",
542
+ "ptpasstime -u http://127.0.0.1:5000/login/vulnerable "
543
+ "-d 'username=admin&password=INJECT' -p correctPassword --brute-force",
544
+ "ptpasstime --request-file login.txt -p correctPassword -n 20",
545
+ ]
546
+ },
547
+ {
548
+ "options": [
549
+ ["-u", "--url", "<url>", "Login endpoint URL"],
550
+ ["-d", "--data", "<post-data>", "POST body with INJECT placeholder for password"],
551
+ ["-f", "--request-file", "<file|base64>", "Raw HTTP request file (alternative to -d)"],
552
+ ["-p", "--password", "<password>", "Known/guessed correct password (reference length and chars)"],
553
+ ["-n", "--repeat", "<n>", "Number of repetitions per attempt (default 10)"],
554
+ ["", "--brute-force", "", "Recover password character-by-character via timing"],
555
+ ["", "--placeholder", "<text>", "Password placeholder in request body (default INJECT)"],
556
+ ["", "--threshold-percent", "<pct>", "Relative timing threshold in percent (default 15)"],
557
+ ["", "--threshold-ms", "<ms>", "Minimum absolute timing threshold in ms (default 1)"],
558
+ ["", "--p-value", "<p>", "Mann-Whitney significance threshold (default 0.01)"],
559
+ ["", "--charset", "<chars>", "Character set for --brute-force mode"],
560
+ ["", "--wrong-char", "<char>", "Wrong character for padding attempts (default X)"],
561
+ ["", "--proxy", "<proxy>", "Set proxy (e.g. http://127.0.0.1:8080)"],
562
+ ["-T", "--timeout", "<seconds>", "Set timeout (default 10)"],
563
+ ["-a", "--user-agent", "<a>", "Set User-Agent header"],
564
+ ["-c", "--cookie", "<cookie>", "Set cookie"],
565
+ ["-H", "--headers", "<header:value>", "Set custom header(s)"],
566
+ ["-r", "--redirects", "", "Follow redirects (default False)"],
567
+ ["-C", "--cache", "", "Cache compatibility flag"],
568
+ ["-vv", "--verbose", "", "Show sample timings (additions output)"],
569
+ ["-v", "--version", "", "Show script version and exit"],
570
+ ["-h", "--help", "", "Show this help message and exit"],
571
+ ["-j", "--json", "", "Output in JSON format"],
572
+ ]
573
+ },
574
+ ]
575
+
576
+
577
+ def parse_args() -> argparse.Namespace:
578
+ parser = argparse.ArgumentParser(add_help=False, description=f"{SCRIPTNAME} <options>")
579
+ parser.add_argument("-u", "--url", type=str)
580
+ parser.add_argument("-d", "--data", type=str, default=None)
581
+ parser.add_argument("-f", "--request-file", type=str, default=None)
582
+ parser.add_argument("-p", "--password", type=str, required=True)
583
+ parser.add_argument("-n", "--repeat", type=int, default=10)
584
+ parser.add_argument("--brute-force", action="store_true")
585
+ parser.add_argument("--placeholder", type=str, default=DEFAULT_PLACEHOLDER)
586
+ parser.add_argument("--threshold-percent", type=float, default=15.0)
587
+ parser.add_argument("--threshold-ms", type=float, default=1.0)
588
+ parser.add_argument("--p-value", type=float, default=0.01)
589
+ parser.add_argument("--charset", type=str, default=DEFAULT_CHARSET)
590
+ parser.add_argument("--wrong-char", type=str, default=DEFAULT_WRONG_CHAR)
591
+ parser.add_argument("--proxy", type=str)
592
+ parser.add_argument("-T", "--timeout", type=int, default=10)
593
+ parser.add_argument("-a", "--user-agent", type=str, default="Penterep Tools")
594
+ parser.add_argument("-c", "--cookie", type=str)
595
+ parser.add_argument("-H", "--headers", type=ptmisclib.pairs, nargs="+")
596
+ parser.add_argument("-vv", "--verbose", action="store_true")
597
+ parser.add_argument("-r", "--redirects", action="store_true")
598
+ parser.add_argument("-C", "--cache", action="store_true")
599
+ parser.add_argument("-j", "--json", action="store_true")
600
+ parser.add_argument("-v", "--version", action="version", version=f"{SCRIPTNAME} {__version__}")
601
+
602
+ parser.add_argument("--socket-address", type=str, default=None)
603
+ parser.add_argument("--socket-port", type=str, default=None)
604
+ parser.add_argument("--process-ident", type=str, default=None)
605
+
606
+ if len(sys.argv) == 1 or "-h" in sys.argv or "--help" in sys.argv:
607
+ ptprinthelper.help_print(get_help(), SCRIPTNAME, __version__)
608
+ sys.exit(0)
609
+
610
+ args = parser.parse_args()
611
+
612
+ if not args.url and not args.request_file:
613
+ parser.error("at least one of --url or --request-file is required")
614
+ if not args.request_file and args.data is None:
615
+ parser.error("-d/--data is required when --request-file is not used")
616
+ if args.repeat < 1:
617
+ parser.error("--repeat must be >= 1")
618
+ if len(args.wrong_char) != 1:
619
+ parser.error("--wrong-char must be a single character")
620
+ if not args.charset:
621
+ parser.error("--charset must not be empty")
622
+
623
+ if args.proxy:
624
+ args.proxy = {"http": args.proxy, "https": args.proxy}
625
+ else:
626
+ args.proxy = {}
627
+
628
+ args.headers = ptnethelper.get_request_headers(args)
629
+ ptprinthelper.print_banner(SCRIPTNAME, __version__, args.json)
630
+ return args
631
+
632
+
633
+ def main() -> None:
634
+ global SCRIPTNAME
635
+ SCRIPTNAME = "ptpasstime"
636
+ requests.packages.urllib3.disable_warnings()
637
+ args = parse_args()
638
+ try:
639
+ script = PtPassTime(args)
640
+ script.run()
641
+ except (ValueError, RuntimeError) as exc:
642
+ ptprint(str(exc), "ERROR", condition=not args.json)
643
+ sys.exit(1)
644
+
645
+
646
+ if __name__ == "__main__":
647
+ main()