Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ via repeatable `--authorized-origin https://approved.example:8443`. Page and
robots redirects outside that scope stop before any destination request and
report `fetch_status=out_of_scope`; robots permission is not authorization.

The CLI also enforces `--target-timeout 120`: a monotonic budget per target shared
by robots, redirects, pacing and HTTP acquisition. `--timeout 15` remains the
additional socket inactivity limit. DNS, connection, TLS, response headers and
slow-trickling bodies run in one disposable request process at a time; on deadline
exhaustion it is killed and reaped before the next target. A failed target records
`fetch_status=deadline_exceeded` and an unknown assessment, rather than a negative
finding. Process startup and cancellation have small OS scheduling overhead.

## Agent skill

The [php2shell-check skill](skills/php2shell-check/SKILL.md) guides an agent
Expand Down
28 changes: 27 additions & 1 deletion docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ are rejected. Input is a text list, **not a CSV or an Excel workbook**.
| `--brand NAME` | `CompleteTech LLC` | Organization name shown on the HTML report. |
| `--delay SECONDS` | `1` | Positive minimum time between requests to an origin. Robots rules may increase it. |
| `--timeout SECONDS` | `15` | Positive per-socket timeout, not a total deadline for a website. |
| `--target-timeout SECONDS` | `120` | Positive monotonic budget shared by all work for each target. |
| `--authorized-origin ORIGIN` | None | Additional authorized redirect origin; repeat for each approved scheme/host/port. No paths, queries or fragments. |
| `--contact VALUE` | Empty | Public contact URL/email included in the User-Agent. |
| `--help` | — | Show command help. |
Expand Down Expand Up @@ -167,6 +168,7 @@ embedded assets, or stale/spoofed metadata can resemble WordPress.

- `ok`: the final page returned HTTP 2xx, even if its content was inconclusive.
- `timeout`: a recognized socket timeout occurred.
- `deadline_exceeded`: the target's total budget was exhausted; assessment remains unknown and the next input can proceed.
- `dns_error`: a recognized DNS-resolution error occurred.
- `out_of_scope`: a page or robots redirect was stopped before requesting an unauthorized origin; assessment remains unknown.
- `blocked`: other cases preventing a successful fetch, including robots rules,
Expand Down Expand Up @@ -271,11 +273,35 @@ upload destination; avoid committing real target lists or scan results.

## Python use

### Total target deadline

The CLI defaults to `--target-timeout 120`. One monotonic budget covers robots,
redirects, required pacing, DNS, connection/TLS, response headers/body and local
assessment. The socket inactivity timeout is an additional bound, never a substitute
for the total budget. Each HTTP acquisition uses one disposable child process;
the parent keeps robots caches and request spacing. Expired acquisition is killed
and reaped before continuing. Windows, macOS and Linux use the same process boundary,
including for resolver calls that Python cannot cancel in a thread. No child is
left running intentionally. Process creation/OS scheduling and cancellation have
small overhead; reaping allows at most two further seconds, then aborts the batch
rather than launching another request if the OS cannot reap the worker.

Deadline exhaustion produces an error row with `fetch_status=deadline_exceeded`
and `php2shell_status=unknown`. Other targets get fresh budgets. A completed batch
can therefore contain incomplete assessments. There are no retries or authorization
exceptions. Required pacing that cannot fit in the remaining budget fails immediately.

Python callers opt into this same boundary with `Checker(target_timeout=120, ...)`.
The low-level `Checker` default remains `target_timeout=None` for integrations with
their own process supervisors; a direct unbounded `request()` is not a total-work
guarantee. Custom in-process opener changes are not transferred to bounded child
requests. The CLI's `--recover` stays offline and launches no HTTP worker.

```python
from dataclasses import asdict
from wordpress_check import Checker

checker = Checker(timeout=15, delay=1, contact="https://your-domain.example/contact")
checker = Checker(timeout=15, target_timeout=120, delay=1, contact="https://your-domain.example/contact")
result = checker.check("https://example.com/")
print(asdict(result))
```
Expand Down
9 changes: 8 additions & 1 deletion skills/php2shell-check/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,14 @@ evidence and timestamps must be preserved; HTML does not expose every field.

`--delay` and `--timeout` must be positive finite numbers. Delay defaults to one
second per origin and can be increased by robots rules. Timeout defaults to 15
seconds **per socket**, not a total run deadline. Add `--contact` only for a
seconds **per socket**. `--target-timeout` defaults to 120 positive finite seconds
per target, shared by robots, redirects, pacing, DNS/connect/TLS, headers and body.
A disposable HTTP acquisition process is killed and reaped on expiry, so stalled
DNS and trickling responses cannot hold the next target indefinitely on Windows,
macOS or Linux. Allow small process-startup/cancellation scheduling overhead.
`fetch_status=deadline_exceeded` means incomplete/unknown, never a negative finding.
The batch continues after that result; completed-input counts do not imply every
assessment succeeded. Add `--contact` only for a
public contact value the user intends to disclose in requests.

Inspect each result even if the process exits with `0`: that means the run
Expand Down
170 changes: 170 additions & 0 deletions test_target_deadline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import contextlib
import io
import json
import subprocess
import sys
import threading
import time
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from unittest.mock import patch

import wordpress_check as checker


class TargetDeadlineTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.requests = []
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
cls.requests.append(self.path)
try:
if self.path == '/robots.txt':
self.send_response(200)
self.end_headers()
self.wfile.write(b'User-agent: *\nDisallow: /private\n')
elif self.path == '/slow':
self.send_response(200)
self.end_headers()
for _ in range(40):
self.wfile.write(b'x')
self.wfile.flush()
time.sleep(.04)
elif self.path == '/idle':
time.sleep(.3)
self.send_response(200)
self.end_headers()
elif self.path == '/unicode':
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.end_headers()
self.wfile.write('<title>\u65e5\u672c\u8a9e \u2603</title>'.encode('utf-8'))
elif self.path.startswith('/redirect'):
time.sleep(.12)
self.send_response(302)
self.send_header('Location', '/redirect' + str(int(self.path[9:]) + 1))
self.end_headers()
else:
self.send_response(200)
self.end_headers()
self.wfile.write(b'<title>synthetic</title>')
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
pass
def log_message(self, *_):
pass
cls.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
cls.url = f'http://127.0.0.1:{cls.server.server_port}'

@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
cls.thread.join()

def setUp(self):
self.requests.clear()

def assert_incomplete(self, result):
self.assertEqual(result.status, 'error')
self.assertEqual(result.fetch_status, 'deadline_exceeded')
self.assertEqual(result.php2shell_status, 'unknown')
self.assertEqual(result.php2shell_exploitability, 'not_tested')

def test_slow_trickle_is_cancelled_and_next_target_progresses(self):
client = checker.Checker(timeout=.2, target_timeout=.65, delay=0)
start = time.monotonic()
self.assert_incomplete(client.check(self.url + '/slow'))
self.assertLess(time.monotonic() - start, 1.5)
# Cache and state survive cancellation, but the target gets a fresh budget.
result = client.check(self.url + '/fast')
self.assertEqual(result.status, 'no_evidence')
self.assertEqual(self.requests.count('/robots.txt'), 1)

def test_redirect_chain_shares_budget(self):
client = checker.Checker(timeout=1, target_timeout=.65, delay=0)
start = time.monotonic()
self.assert_incomplete(client.check(self.url + '/redirect0'))
self.assertLess(time.monotonic() - start, 1.5)
self.assertLess(len([p for p in self.requests if p.startswith('/redirect')]), 6)

def test_pacing_does_not_sleep_past_budget_or_fetch_page(self):
client = checker.Checker(timeout=1, target_timeout=.5, delay=1)
start = time.monotonic()
self.assert_incomplete(client.check(self.url + '/fast'))
self.assertLess(time.monotonic() - start, 1)
self.assertEqual(self.requests, ['/robots.txt'])

def test_dns_and_connect_stalls_are_killed_without_surviving_workers(self):
real_popen = subprocess.Popen
for operation in ('dns', 'connect'):
with self.subTest(operation=operation):
children = []
def spawn(command, **kwargs):
script = "import runpy,sys,time;m=runpy.run_path(sys.argv[1]);"
if operation == 'dns':
script += "m['socket'].getaddrinfo=lambda *a,**k:time.sleep(20);"
else:
script += "m['socket'].socket.connect=lambda *a,**k:time.sleep(20);"
script += "m['_request_worker']()"
child = real_popen([sys.executable, '-I', '-c', script, str(Path(checker.__file__).resolve())], **kwargs)
children.append(child)
return child
start = time.monotonic()
with patch.object(checker.subprocess, 'Popen', side_effect=spawn):
result = checker.Checker(timeout=.1, target_timeout=.4, delay=0).check(self.url)
self.assert_incomplete(result)
self.assertLess(time.monotonic() - start, 1.4)
self.assertEqual(len(children), 1)
self.assertIsNotNone(children[0].poll())

def test_cli_deadline_keeps_result_and_continues_batch(self):
out = io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()):
code = checker.main([self.url + '/slow', self.url + '/fast', '--format', 'json',
'--timeout', '.2', '--target-timeout', '.65', '--delay', '.001'])
report = json.loads(out.getvalue())
self.assertEqual(code, 0)
self.assertTrue(report['complete']) # All inputs handled; first assessment failed.
self.assertEqual(report['results'][0]['fetch_status'], 'deadline_exceeded')
self.assertEqual(report['results'][0]['php2shell_status'], 'unknown')
self.assertEqual(report['results'][1]['status'], 'no_evidence')

def test_robots_scope_and_socket_timeout_remain_active(self):
client = checker.Checker(timeout=.05, target_timeout=2, delay=0)
result = client.check(self.url + '/private')
self.assertEqual(result.status, 'blocked')
self.assertNotIn('/private', self.requests)
# An inactivity timeout remains distinct from total deadline exhaustion.
with patch.object(client, '_fetch', side_effect=AssertionError('parent HTTP forbidden')):
self.assertEqual(client.check(self.url + '/fast').status, 'no_evidence')
self.assertEqual(client.check(self.url + '/idle').fetch_status, 'timeout')

def test_expiry_during_parsing_never_publishes_a_positive_assessment(self):
client = checker.Checker(target_timeout=1, delay=0)
client.request = lambda *args: checker.Response(200, {}, '', False)
client.robots[self.url] = (None, 0, 'synthetic robots')
def late_parse(response, result):
result.php_version = '8.3.36'
client._deadline = 0
with patch.object(checker, 'inspect_page', side_effect=late_parse):
self.assert_incomplete(client.check(self.url))

def test_invalid_total_budgets_are_rejected(self):
for value in (0, -1, float('nan'), float('inf')):
with self.assertRaises(ValueError):
checker.Checker(target_timeout=value)
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
checker.main(['example.test', '--target-timeout', '0'])

def test_unicode_response_survives_worker_boundary(self):
result = checker.Checker(timeout=1, target_timeout=2, delay=0).check(self.url + '/unicode')
self.assertEqual(result.status, 'no_evidence')
self.assertEqual(result.title, '\u65e5\u672c\u8a9e \u2603')


if __name__ == '__main__':
unittest.main()
Loading
Loading