rcekit 2.35.0__tar.gz → 2.35.1__tar.gz

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.
Files changed (30) hide show
  1. {rcekit-2.35.0 → rcekit-2.35.1}/CHANGELOG.md +41 -1
  2. {rcekit-2.35.0 → rcekit-2.35.1}/PKG-INFO +2 -2
  3. {rcekit-2.35.0 → rcekit-2.35.1}/README.md +1 -1
  4. {rcekit-2.35.0 → rcekit-2.35.1}/rcekit.egg-info/PKG-INFO +2 -2
  5. {rcekit-2.35.0 → rcekit-2.35.1}/rcekit.py +152 -8
  6. {rcekit-2.35.0 → rcekit-2.35.1}/tests/test_generator.py +212 -0
  7. {rcekit-2.35.0 → rcekit-2.35.1}/CONTRIBUTING.md +0 -0
  8. {rcekit-2.35.0 → rcekit-2.35.1}/LICENSE +0 -0
  9. {rcekit-2.35.0 → rcekit-2.35.1}/MANIFEST.in +0 -0
  10. {rcekit-2.35.0 → rcekit-2.35.1}/SECURITY.md +0 -0
  11. {rcekit-2.35.0 → rcekit-2.35.1}/docs/generation.md +0 -0
  12. {rcekit-2.35.0 → rcekit-2.35.1}/docs/guide.md +0 -0
  13. {rcekit-2.35.0 → rcekit-2.35.1}/docs/reference.md +0 -0
  14. {rcekit-2.35.0 → rcekit-2.35.1}/docs/verify-it-yourself.md +0 -0
  15. {rcekit-2.35.0 → rcekit-2.35.1}/pyproject.toml +0 -0
  16. {rcekit-2.35.0 → rcekit-2.35.1}/rcekit.egg-info/SOURCES.txt +0 -0
  17. {rcekit-2.35.0 → rcekit-2.35.1}/rcekit.egg-info/dependency_links.txt +0 -0
  18. {rcekit-2.35.0 → rcekit-2.35.1}/rcekit.egg-info/entry_points.txt +0 -0
  19. {rcekit-2.35.0 → rcekit-2.35.1}/rcekit.egg-info/top_level.txt +0 -0
  20. {rcekit-2.35.0 → rcekit-2.35.1}/setup.cfg +0 -0
  21. {rcekit-2.35.0 → rcekit-2.35.1}/templates/payloads.json +0 -0
  22. {rcekit-2.35.0 → rcekit-2.35.1}/tests/bench/README.md +0 -0
  23. {rcekit-2.35.0 → rcekit-2.35.1}/tests/bench/cases/struts2-s2-001.json +0 -0
  24. {rcekit-2.35.0 → rcekit-2.35.1}/tests/bench/cases/webmin-cve-2019-15107.json +0 -0
  25. {rcekit-2.35.0 → rcekit-2.35.1}/tests/bench/requests/webmin.txt +0 -0
  26. {rcekit-2.35.0 → rcekit-2.35.1}/tests/bench/runner.py +0 -0
  27. {rcekit-2.35.0 → rcekit-2.35.1}/tests/test_bench_runner.py +0 -0
  28. {rcekit-2.35.0 → rcekit-2.35.1}/tests/test_corpus_embedding.py +0 -0
  29. {rcekit-2.35.0 → rcekit-2.35.1}/tests/test_docs.py +0 -0
  30. {rcekit-2.35.0 → rcekit-2.35.1}/tools/embed_corpus.py +0 -0
@@ -8,6 +8,45 @@ formats, or the template schema.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [2.35.1] — 2026-08-21
12
+
13
+ A robustness pass over error handling: no new capability, four ways the tool
14
+ could crash or mislead on input it did not choose.
15
+
16
+ ### Fixed
17
+
18
+ - **A truncated error response no longer ends the run.** Reading an
19
+ `HTTPError`'s body happens *inside* the `except` handler, where the sibling
20
+ `except Exception` cannot reach it — so a target that promised a
21
+ `Content-Length` it never delivered raised `ConnectionResetError` straight out
22
+ of `main()`, taking every probe already fired with it. The read is now
23
+ guarded: the status still comes back, an unreadable body is reported empty.
24
+ A body that *does* arrive is still returned in full — the 500-stack-trace
25
+ confirmations that branch exists for are unaffected.
26
+ - **A `--target-profile` is checked before it is used.** A profile is written by
27
+ hand, so a typo in one is ordinary; it surfaced as a traceback. A top level
28
+ that is not a JSON object, `deny_chars` that is not text, a `max_length` that
29
+ is not a number, a selector field that is not a list of names — each is now an
30
+ operator-readable `[!]` message and exit 1, the same way the sink-shape fields
31
+ already behaved. Twelve inputs that produced an `AttributeError` or a
32
+ `TypeError` now produce a sentence.
33
+ - **A selector field given as one string means one name.** `"environments":
34
+ "unix"` in a profile was iterated character by character, matched nothing, and
35
+ the empty run that followed was reported as a success. A string is now split
36
+ on commas: `"unix"` is `["unix"]`, `"raw, html"` is `["raw", "html"]`.
37
+ - **Unknown `--environments` and `--encodings` are named.** Both were silent, so
38
+ `--environments linux` — the corpus calls it `unix` — produced an empty file
39
+ and exit 0, indistinguishable from a target with no payloads for it. Both now
40
+ warn and list the names that exist, as unknown contexts and categories already
41
+ did. An empty result is also no longer announced as "Successfully generated 0
42
+ payloads"; it says the selection matched nothing and points at the filters.
43
+ - **A callback cannot rewrite the operator's terminal.** The host and path of an
44
+ OOB callback are chosen by the target. Printed raw, an ESC byte let that
45
+ target colour, erase and rewrite lines — hiding a genuine `[HIT]` behind
46
+ `\x1b[2K\r`, or forging one that never arrived. Control characters are now
47
+ escaped for display as `\xNN`; the recorded hit and the `--listen-log` JSONL
48
+ keep the bytes verbatim.
49
+
11
50
  ## [2.35.0] — 2026-08-20
12
51
 
13
52
  ### Added
@@ -977,7 +1016,8 @@ this file and have not been restated here.
977
1016
  - **[2.7.0]**
978
1017
  - **[2.1.0]**
979
1018
 
980
- [Unreleased]: https://github.com/kabiri-labs/rcekit/compare/v2.35.0...HEAD
1019
+ [Unreleased]: https://github.com/kabiri-labs/rcekit/compare/v2.35.1...HEAD
1020
+ [2.35.1]: https://github.com/kabiri-labs/rcekit/compare/v2.35.0...v2.35.1
981
1021
  [2.35.0]: https://github.com/kabiri-labs/rcekit/compare/v2.34.1...v2.35.0
982
1022
  [2.34.1]: https://github.com/kabiri-labs/rcekit/compare/v2.34.0...v2.34.1
983
1023
  [2.34.0]: https://github.com/kabiri-labs/rcekit/compare/v2.33.0...v2.34.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rcekit
3
- Version: 2.35.0
3
+ Version: 2.35.1
4
4
  Summary: RCE detection & confirmation toolkit for authorised penetration testing
5
5
  Author: Ahmad Kabiri
6
6
  License-Expression: MIT
@@ -24,7 +24,7 @@ Dynamic: license-file
24
24
 
25
25
  **`confirmed` means the target executed the input. `negative` means the probes reached it.**
26
26
 
27
- **Version 2.35.0** · MIT · Python 3.8+ · zero third-party dependencies
27
+ **Version 2.35.1** · MIT · Python 3.8+ · zero third-party dependencies
28
28
 
29
29
  RCEKit is an **RCE detection & confirmation toolkit** for authorised penetration
30
30
  testing, red teaming and security research. Point it at a target you are allowed
@@ -2,7 +2,7 @@
2
2
 
3
3
  **`confirmed` means the target executed the input. `negative` means the probes reached it.**
4
4
 
5
- **Version 2.35.0** · MIT · Python 3.8+ · zero third-party dependencies
5
+ **Version 2.35.1** · MIT · Python 3.8+ · zero third-party dependencies
6
6
 
7
7
  RCEKit is an **RCE detection & confirmation toolkit** for authorised penetration
8
8
  testing, red teaming and security research. Point it at a target you are allowed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rcekit
3
- Version: 2.35.0
3
+ Version: 2.35.1
4
4
  Summary: RCE detection & confirmation toolkit for authorised penetration testing
5
5
  Author: Ahmad Kabiri
6
6
  License-Expression: MIT
@@ -24,7 +24,7 @@ Dynamic: license-file
24
24
 
25
25
  **`confirmed` means the target executed the input. `negative` means the probes reached it.**
26
26
 
27
- **Version 2.35.0** · MIT · Python 3.8+ · zero third-party dependencies
27
+ **Version 2.35.1** · MIT · Python 3.8+ · zero third-party dependencies
28
28
 
29
29
  RCEKit is an **RCE detection & confirmation toolkit** for authorised penetration
30
30
  testing, red teaming and security research. Point it at a target you are allowed
@@ -45,7 +45,7 @@ def configure_logging() -> None:
45
45
 
46
46
  # Bump on every change: PATCH for fixes, MINOR for new capabilities, MAJOR for
47
47
  # breaking changes to the CLI, output formats, or template schema.
48
- __version__ = "2.35.0"
48
+ __version__ = "2.35.1"
49
49
 
50
50
  SAFETY_ORDER = {"safe": 0, "intrusive": 1, "stateful": 2}
51
51
 
@@ -1022,6 +1022,39 @@ class RCEKit:
1022
1022
  return payload.replace("\r", " ").replace("\n", " ")
1023
1023
  return payload
1024
1024
 
1025
+ def _known_environments(self, mode: str) -> Set[str]:
1026
+ """Every environment name the corpus can actually yield in ``mode``."""
1027
+ if mode == "detection":
1028
+ return set(self.detection_payloads)
1029
+ known: Set[str] = set()
1030
+ for category in self.payload_categories.values():
1031
+ known.update(category)
1032
+ return known
1033
+
1034
+ def _warn_unknown_selectors(self, mode: str,
1035
+ selected_environments: Optional[List[str]],
1036
+ selected_encodings: Optional[List[str]]) -> None:
1037
+ """Name any explicitly selected environment or encoding that matches
1038
+ nothing, once each.
1039
+
1040
+ A selector that matches nothing produces an empty run, and an empty run
1041
+ is reported as a success -- so ``--environments linux`` (the corpus
1042
+ calls it ``unix``) is indistinguishable from a target for which there
1043
+ are simply no payloads. Unknown contexts and categories were already
1044
+ named; these two went by in silence. Only *explicit* selections are
1045
+ checked: the defaults are the corpus's own and must not warn."""
1046
+ if selected_environments:
1047
+ known = self._known_environments(mode)
1048
+ for env in dict.fromkeys(selected_environments):
1049
+ if env not in known:
1050
+ logger.warning("Unknown environment: %s (known: %s)",
1051
+ env, ", ".join(sorted(known)))
1052
+ if selected_encodings:
1053
+ for enc in dict.fromkeys(selected_encodings):
1054
+ if enc not in self.encoding_methods:
1055
+ logger.warning("Unknown encoding: %s (known: %s)",
1056
+ enc, ", ".join(sorted(self.encoding_methods)))
1057
+
1025
1058
  def generate_payload_records(
1026
1059
  self,
1027
1060
  selected_contexts: Optional[List[str]] = None,
@@ -1039,6 +1072,7 @@ class RCEKit:
1039
1072
  encodings = selected_encodings if selected_encodings else (
1040
1073
  self.safe_detection_encodings if mode == "detection" else list(self.default_encodings)
1041
1074
  )
1075
+ self._warn_unknown_selectors(mode, selected_environments, selected_encodings)
1042
1076
 
1043
1077
  if mode == "detection":
1044
1078
  environments = selected_environments if selected_environments else list(self.detection_payloads.keys())
@@ -1497,7 +1531,18 @@ class RCEKit:
1497
1531
  for entry in manifest:
1498
1532
  map_file.write(json.dumps(entry, ensure_ascii=True) + "\n")
1499
1533
 
1500
- logger.info("Successfully generated %s payloads to %s", count, output_path)
1534
+ if count:
1535
+ logger.info("Successfully generated %s payloads to %s", count, output_path)
1536
+ else:
1537
+ # "Successfully generated 0 payloads" reads as a target with
1538
+ # nothing to fire at. Far more often it is a selector that
1539
+ # matched nothing, and the operator needs to hear the
1540
+ # difference before they conclude anything from the run.
1541
+ logger.warning(
1542
+ "No payloads matched the selection -- %s is empty. Check "
1543
+ "--contexts/--categories/--encodings/--environments and any "
1544
+ "--target-profile filters (--deny-chars, --max-length).",
1545
+ output_path)
1501
1546
  if include_metadata and output_format == "text":
1502
1547
  logger.info("Metadata sidecar written to %s", metadata_path)
1503
1548
  if manifest:
@@ -2044,7 +2089,18 @@ class RCEKit:
2044
2089
  return (response.status, text, self._channels_from_response(response, text, target),
2045
2090
  time.time() - start)
2046
2091
  except urllib.error.HTTPError as exc:
2047
- text = exc.read().decode(errors="replace")
2092
+ # Reading the error body can itself fail: a target that truncates
2093
+ # its own error response -- a Content-Length it never delivers,
2094
+ # then a reset -- raises here, mid-`except`. An exception raised
2095
+ # inside an except block is NOT caught by the sibling handler
2096
+ # below, so without this guard one malformed error response ends
2097
+ # the whole run and every probe fired so far is lost. The status
2098
+ # is what the caller needs most; an unreadable body is a body we
2099
+ # report as empty, exactly as a delivery failure would.
2100
+ try:
2101
+ text = exc.read().decode(errors="replace")
2102
+ except Exception:
2103
+ text = ""
2048
2104
  return (exc.code, text, self._channels_from_response(exc, text, target),
2049
2105
  time.time() - start)
2050
2106
  except Exception as exc: # network error, timeout, etc.
@@ -2892,6 +2948,31 @@ class RCEKit:
2892
2948
  except Exception as exc:
2893
2949
  logger.error("Unable to log exploitation usage: %s", exc)
2894
2950
 
2951
+
2952
+ def terminal_safe(text: str) -> str:
2953
+ """Escape control characters for display on the operator's terminal.
2954
+
2955
+ A callback's host and path are chosen by the *target*, which in an
2956
+ engagement is hostile by definition. Printed raw, an ESC byte lets that
2957
+ target colour, erase and rewrite lines in the operator's own terminal --
2958
+ hiding a genuine hit behind ``\\x1b[2K\\r``, or forging one that never
2959
+ arrived. For a tool whose whole claim is that its output can be trusted,
2960
+ that is the output being forged.
2961
+
2962
+ Escaped rather than stripped: what arrived is still visible, as ``\\xNN``,
2963
+ so the operator can see that something tried this. Only the display is
2964
+ changed -- the recorded hit and the JSONL log keep the bytes verbatim."""
2965
+ out: List[str] = []
2966
+ for char in text:
2967
+ if char.isprintable():
2968
+ out.append(char)
2969
+ elif ord(char) <= 0xFF:
2970
+ out.append("\\x%02x" % ord(char))
2971
+ else:
2972
+ out.append("\\u%04x" % ord(char))
2973
+ return "".join(out)
2974
+
2975
+
2895
2976
  class OOBListener:
2896
2977
  """A lightweight out-of-band listener that records DNS/HTTP callbacks and
2897
2978
  correlates each one back to the payload whose unique token produced it.
@@ -2952,12 +3033,19 @@ class OOBListener:
2952
3033
  "context": entry.get("context") if entry else None,
2953
3034
  }
2954
3035
  self.hits.append(hit)
2955
- where = f"{host or path}"
3036
+ # Everything interpolated below reached us over the wire (or, for the
3037
+ # manifest fields, out of a file the operator may have hand-edited),
3038
+ # so none of it goes to the terminal unescaped. The hit recorded above
3039
+ # keeps the raw bytes.
3040
+ where = terminal_safe(f"{host or path}")
3041
+ safe_token = terminal_safe(token) if token else token
2956
3042
  if entry:
2957
- print(f"[HIT] {proto} token={token} from {source} -> {entry.get('payload')} "
2958
- f"[{entry.get('category')}/{entry.get('context')}]")
3043
+ print(f"[HIT] {proto} token={safe_token} from {source} "
3044
+ f"-> {terminal_safe(str(entry.get('payload')))} "
3045
+ f"[{terminal_safe(str(entry.get('category')))}/"
3046
+ f"{terminal_safe(str(entry.get('context')))}]")
2959
3047
  else:
2960
- print(f"[HIT] {proto} from {source} -> {where} (token={token}; not in manifest)")
3048
+ print(f"[HIT] {proto} from {source} -> {where} (token={safe_token}; not in manifest)")
2961
3049
  if self.log_path:
2962
3050
  try:
2963
3051
  with open(self.log_path, "a", encoding="utf-8") as log_file:
@@ -5576,6 +5664,62 @@ def build_request_inputs(text: str, param: Optional[str] = None,
5576
5664
  return url, method, data, out_headers, injection
5577
5665
 
5578
5666
 
5667
+ # Selector fields a profile may give as a list of names or as one
5668
+ # comma-separated string. Normalised to a list, because a bare string handed
5669
+ # to the generator would be iterated *character by character* -- "unix" would
5670
+ # select nothing at all, silently.
5671
+ _PROFILE_LIST_FIELDS = ("environments", "contexts", "categories", "encodings",
5672
+ "sink_decodes")
5673
+ # Fields whose string form main() already knows how to split itself.
5674
+ _PROFILE_STRING_OR_LIST_FIELDS = ("separators", "eval_engines")
5675
+
5676
+
5677
+ def validate_target_profile(profile: Any) -> Dict[str, Any]:
5678
+ """Check a loaded target profile's shape and normalise its selector fields,
5679
+ raising ``ValueError`` with an operator-readable message.
5680
+
5681
+ A profile is a file someone writes by hand, so the failure to design for is
5682
+ a typo: a number where a character set belongs, a bare string where a list
5683
+ does. Left unchecked those surface far from their cause -- as a ``TypeError``
5684
+ deep in the payload filter, or as a run that quietly generates nothing.
5685
+ Every check here guards a field the caller goes on to join, index, or
5686
+ compare."""
5687
+ if not isinstance(profile, dict):
5688
+ raise ValueError(f"a target profile must be a JSON object, not "
5689
+ f"{type(profile).__name__}.")
5690
+
5691
+ def _names(field: str, value: Any) -> List[str]:
5692
+ if isinstance(value, str):
5693
+ return [part.strip() for part in value.split(",") if part.strip()]
5694
+ if isinstance(value, (list, tuple)) and all(isinstance(v, str) for v in value):
5695
+ return list(value)
5696
+ raise ValueError(f"'{field}' must be a string or a list of strings, "
5697
+ f"not {type(value).__name__}.")
5698
+
5699
+ for field in _PROFILE_LIST_FIELDS:
5700
+ if profile.get(field) is not None:
5701
+ profile[field] = _names(field, profile[field])
5702
+ for field in _PROFILE_STRING_OR_LIST_FIELDS:
5703
+ if profile.get(field) is not None and not isinstance(profile[field], str):
5704
+ _names(field, profile[field])
5705
+
5706
+ deny = profile.get("deny_chars")
5707
+ # A string is already a set of characters; a list is joined into one.
5708
+ if deny is not None and not isinstance(deny, str):
5709
+ _names("deny_chars", deny)
5710
+
5711
+ length = profile.get("max_length")
5712
+ if length is not None and (isinstance(length, bool) or not isinstance(length, int)):
5713
+ raise ValueError(f"'max_length' must be a whole number, not "
5714
+ f"{type(length).__name__}.")
5715
+
5716
+ request = profile.get("request")
5717
+ if request is not None and not isinstance(request, dict):
5718
+ raise ValueError(f"'request' must be a JSON object, not "
5719
+ f"{type(request).__name__}.")
5720
+ return profile
5721
+
5722
+
5579
5723
  def load_token_manifest(path: str) -> Dict[str, Dict[str, Any]]:
5580
5724
  """Load a .map.jsonl manifest into a token -> entry dict."""
5581
5725
  tokens: Dict[str, Dict[str, Any]] = {}
@@ -5913,7 +6057,7 @@ def main(argv: Optional[List[str]] = None) -> int:
5913
6057
  if args.target_profile:
5914
6058
  try:
5915
6059
  with open(args.target_profile, "r", encoding="utf-8") as profile_file:
5916
- profile = json.load(profile_file)
6060
+ profile = validate_target_profile(json.load(profile_file))
5917
6061
  except Exception as exc:
5918
6062
  print(f"[!] Unable to load target profile {args.target_profile}: {exc}")
5919
6063
  return 1
@@ -6256,3 +6256,215 @@ class DeserializationSinkTestCase(unittest.TestCase):
6256
6256
 
6257
6257
  if __name__ == "__main__":
6258
6258
  unittest.main()
6259
+
6260
+
6261
+ class TruncatedErrorResponseTestCase(unittest.TestCase):
6262
+ """A target may cut its own error response short. Reading that body raises
6263
+ *inside* the ``except HTTPError`` handler, where the sibling ``except
6264
+ Exception`` cannot catch it -- so before the guard, one malformed error
6265
+ response ended the whole run and lost every probe already fired."""
6266
+
6267
+ def _serve(self, handler):
6268
+ import socket
6269
+ import threading
6270
+ server = socket.socket()
6271
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
6272
+ server.bind(("127.0.0.1", 0))
6273
+ server.listen(8)
6274
+ port = server.getsockname()[1]
6275
+
6276
+ def loop():
6277
+ while True:
6278
+ try:
6279
+ conn, _ = server.accept()
6280
+ except OSError:
6281
+ return
6282
+ threading.Thread(target=handler, args=(conn,), daemon=True).start()
6283
+
6284
+ threading.Thread(target=loop, daemon=True).start()
6285
+ self.addCleanup(server.close)
6286
+ return port
6287
+
6288
+ def test_error_body_that_never_arrives_does_not_end_the_run(self):
6289
+ def truncate(conn):
6290
+ # Promise 4000 bytes, send 10, then reset the connection.
6291
+ conn.sendall(b"HTTP/1.1 500 Internal Server Error\r\n"
6292
+ b"Content-Length: 4000\r\n\r\n" + b"A" * 10)
6293
+ conn.close()
6294
+
6295
+ port = self._serve(truncate)
6296
+ generator = RCEKit()
6297
+ status, body, channels, elapsed = generator._fire_channels(
6298
+ "probe", f"http://127.0.0.1:{port}/x?cmd=FUZZ", "GET", None, None,
6299
+ "query_value", "json_string", 5)
6300
+ # The status is what the caller needs most; an unreadable body is
6301
+ # reported as empty rather than as a traceback.
6302
+ self.assertIn(status, (500, None))
6303
+ self.assertIsInstance(body, str)
6304
+ self.assertIsInstance(channels, list)
6305
+ self.assertGreaterEqual(elapsed, 0.0)
6306
+
6307
+ def test_readable_error_body_is_still_returned(self):
6308
+ """The guard must not cost the 500-stack-trace confirmations it exists
6309
+ alongside: a complete error body still comes back in full."""
6310
+ def complete(conn):
6311
+ body = b"computed 1355862 here"
6312
+ conn.sendall(b"HTTP/1.1 500 Internal Server Error\r\n"
6313
+ b"Content-Length: %d\r\n\r\n" % len(body) + body)
6314
+ conn.close()
6315
+
6316
+ port = self._serve(complete)
6317
+ generator = RCEKit()
6318
+ status, body, _, _ = generator._fire_channels(
6319
+ "probe", f"http://127.0.0.1:{port}/x?cmd=FUZZ", "GET", None, None,
6320
+ "query_value", "json_string", 5)
6321
+ self.assertEqual(status, 500)
6322
+ self.assertIn("1355862", body)
6323
+
6324
+
6325
+ class TargetProfileValidationTestCase(unittest.TestCase):
6326
+ """A profile is hand-edited, so a typo in one is ordinary. It must produce
6327
+ an operator-readable message, not a traceback thousands of payloads later."""
6328
+
6329
+ def test_top_level_must_be_an_object(self):
6330
+ for value in ([], None, "hello", 42, True):
6331
+ with self.subTest(value=value):
6332
+ with self.assertRaises(ValueError) as caught:
6333
+ rcekit.validate_target_profile(value)
6334
+ self.assertIn("must be a JSON object", str(caught.exception))
6335
+
6336
+ def test_selector_fields_reject_non_string_values(self):
6337
+ for field in ("environments", "contexts", "categories", "encodings",
6338
+ "sink_decodes"):
6339
+ with self.subTest(field=field):
6340
+ with self.assertRaises(ValueError) as caught:
6341
+ rcekit.validate_target_profile({field: 5})
6342
+ self.assertIn(field, str(caught.exception))
6343
+
6344
+ def test_selector_string_is_one_name_not_one_character_each(self):
6345
+ """``"unix"`` iterated as characters selected nothing at all, and the
6346
+ empty run that followed was reported as a success."""
6347
+ profile = rcekit.validate_target_profile({"environments": "unix"})
6348
+ self.assertEqual(profile["environments"], ["unix"])
6349
+ profile = rcekit.validate_target_profile({"contexts": "raw, html"})
6350
+ self.assertEqual(profile["contexts"], ["raw", "html"])
6351
+
6352
+ def test_deny_chars_accepts_a_string_or_a_list(self):
6353
+ self.assertEqual(
6354
+ rcekit.validate_target_profile({"deny_chars": ";|&"})["deny_chars"], ";|&")
6355
+ self.assertEqual(
6356
+ rcekit.validate_target_profile({"deny_chars": [";", "|"]})["deny_chars"],
6357
+ [";", "|"])
6358
+ for bad in (123, True, {"a": 1}):
6359
+ with self.subTest(bad=bad):
6360
+ with self.assertRaises(ValueError):
6361
+ rcekit.validate_target_profile({"deny_chars": bad})
6362
+
6363
+ def test_max_length_must_be_a_whole_number(self):
6364
+ self.assertEqual(
6365
+ rcekit.validate_target_profile({"max_length": 120})["max_length"], 120)
6366
+ for bad in ("abc", [1], {}, True):
6367
+ with self.subTest(bad=bad):
6368
+ with self.assertRaises(ValueError) as caught:
6369
+ rcekit.validate_target_profile({"max_length": bad})
6370
+ self.assertIn("max_length", str(caught.exception))
6371
+
6372
+ def test_request_must_be_an_object(self):
6373
+ with self.assertRaises(ValueError) as caught:
6374
+ rcekit.validate_target_profile({"request": 5})
6375
+ self.assertIn("request", str(caught.exception))
6376
+
6377
+ def test_shipped_profiles_all_validate(self):
6378
+ shipped = sorted((Path(__file__).resolve().parent.parent / "profiles").glob("*.json"))
6379
+ self.assertTrue(shipped, "no profiles shipped to validate")
6380
+ for path in shipped:
6381
+ with self.subTest(profile=path.name):
6382
+ with path.open(encoding="utf-8") as handle:
6383
+ rcekit.validate_target_profile(json.load(handle))
6384
+
6385
+
6386
+ class UnknownSelectorWarningTestCase(unittest.TestCase):
6387
+ """A selector matching nothing yields an empty run, and an empty run reads
6388
+ as 'this target has no payloads'. Unknown names have to be named."""
6389
+
6390
+ def test_unknown_environment_is_named(self):
6391
+ generator = RCEKit()
6392
+ with self.assertLogs("rcekit", level="WARNING") as logs:
6393
+ list(generator.generate_payload_records(selected_environments=["linux"]))
6394
+ joined = "\n".join(logs.output)
6395
+ self.assertIn("Unknown environment: linux", joined)
6396
+ # The message has to carry the answer, not just the complaint.
6397
+ self.assertIn("unix", joined)
6398
+
6399
+ def test_unknown_encoding_is_named(self):
6400
+ generator = RCEKit()
6401
+ with self.assertLogs("rcekit", level="WARNING") as logs:
6402
+ list(generator.generate_payload_records(selected_encodings=["nosuchenc"]))
6403
+ self.assertIn("Unknown encoding: nosuchenc", "\n".join(logs.output))
6404
+
6405
+ def test_known_selectors_stay_quiet(self):
6406
+ generator = RCEKit()
6407
+ with self.assertLogs("rcekit", level="WARNING") as logs:
6408
+ logger_under_test = rcekit.logger
6409
+ logger_under_test.warning("sentinel")
6410
+ list(generator.generate_payload_records(
6411
+ selected_environments=["unix"], selected_encodings=["url_encode"]))
6412
+ self.assertEqual([line for line in logs.output if "Unknown" in line], [])
6413
+
6414
+ def test_defaults_never_warn(self):
6415
+ """The default lists are the corpus's own; warning on them would make
6416
+ every ordinary run noisy."""
6417
+ generator = RCEKit()
6418
+ with self.assertLogs("rcekit", level="WARNING") as logs:
6419
+ rcekit.logger.warning("sentinel")
6420
+ list(generator.generate_payload_records(max_safety="safe"))
6421
+ self.assertEqual(
6422
+ [line for line in logs.output if "Unknown environment" in line], [])
6423
+
6424
+
6425
+ class TerminalSafeOutputTestCase(unittest.TestCase):
6426
+ """A callback's host and path are chosen by the target. Printed raw, an ESC
6427
+ byte lets that target rewrite the operator's terminal -- hiding a real hit
6428
+ or forging one."""
6429
+
6430
+ def test_control_bytes_are_escaped(self):
6431
+ self.assertEqual(rcekit.terminal_safe("a\x1b[31mb"), "a\\x1b[31mb")
6432
+ self.assertEqual(rcekit.terminal_safe("x\ry\nz"), "x\\x0dy\\x0az")
6433
+ self.assertEqual(rcekit.terminal_safe("nul\x00here"), "nul\\x00here")
6434
+
6435
+ def test_printable_text_is_untouched(self):
6436
+ for text in ("plain.host.example", "/path?q=1", "; curl http://x/", "héllo"):
6437
+ with self.subTest(text=text):
6438
+ self.assertEqual(rcekit.terminal_safe(text), text)
6439
+
6440
+ def test_non_bmp_and_separator_codepoints_are_escaped(self):
6441
+ # U+2028 LINE SEPARATOR is non-printable but above 0xFF.
6442
+ self.assertEqual(rcekit.terminal_safe("a
b"), "a\\u2028b")
6443
+
6444
+ def test_listener_never_prints_a_raw_escape(self):
6445
+ import contextlib
6446
+ import io
6447
+ listener = OOBListener()
6448
+ hostile = "evil\x1b[31mRED\x1b[0m\x1b[2K\rspoofed"
6449
+ buffer = io.StringIO()
6450
+ with contextlib.redirect_stdout(buffer):
6451
+ hit = listener.record("http", "10.0.0.5", hostile, "/cb")
6452
+ printed = buffer.getvalue()
6453
+ self.assertNotIn("\x1b", printed)
6454
+ self.assertNotIn("\r", printed)
6455
+ self.assertIn("\\x1b", printed)
6456
+ # The record itself stays faithful -- only the display is escaped.
6457
+ self.assertEqual(hit["host"], hostile)
6458
+
6459
+ def test_manifest_payload_is_escaped_on_the_hit_line(self):
6460
+ tokens = {"tok1": {"payload": "; curl http://tok1.oob.test/\r\n",
6461
+ "category": "oob", "context": "raw"}}
6462
+ listener = OOBListener(tokens=tokens)
6463
+ import contextlib
6464
+ import io
6465
+ buffer = io.StringIO()
6466
+ with contextlib.redirect_stdout(buffer):
6467
+ listener.record("dns", "10.0.0.5", "tok1.oob.test", "")
6468
+ printed = buffer.getvalue()
6469
+ self.assertNotIn("\r", printed)
6470
+ self.assertIn("\\x0d", printed)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes