Skip to content

Commit ad3c4ba

Browse files
author
0
committed
test: add unit test suite for dry-run mode, Nuclei export, tech-CVE mapping, and Burp import functionality
1 parent 0b4c9b7 commit ad3c4ba

7 files changed

Lines changed: 656 additions & 102 deletions

File tree

src/spring2shell/cli.py

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,33 @@ def _cmd_encrypt(args: argparse.Namespace) -> int:
163163
return 0
164164

165165

166+
def _cmd_nuclei_export(args: argparse.Namespace) -> int:
167+
"""Export scan findings as Nuclei v3 YAML templates."""
168+
import json
169+
from pathlib import Path
170+
from spring2shell.utils.nuclei_export import export_templates
171+
172+
report_path = Path(args.report_file)
173+
if not report_path.exists():
174+
print(f"[!] Report file not found: {report_path}")
175+
return 1
176+
try:
177+
data = json.loads(report_path.read_text())
178+
findings = data.get("findings", data) if isinstance(data, dict) else data
179+
except Exception as exc:
180+
print(f"[!] Could not parse report: {exc}")
181+
return 1
182+
created = export_templates(findings, args.output_dir)
183+
print(f"[+] {len(created)} Nuclei templates written to {args.output_dir}")
184+
for p in created[:10]:
185+
print(f" {p}")
186+
if len(created) > 10:
187+
print(f" ... and {len(created) - 10} more")
188+
return 0
189+
190+
166191
def _cmd_verify(args: argparse.Namespace) -> int:
192+
167193
"""Verify a potential RCE: echo-marker test + optional blind (time-delay + DNS)."""
168194
from spring2shell.core.verifier import check_real_rce, blind_rce_test
169195

@@ -422,6 +448,11 @@ def build_parser() -> argparse.ArgumentParser:
422448
default="default",
423449
help="Runtime profile (timeout, retries, delay). Default: default.",
424450
)
451+
parser.add_argument(
452+
"--dry-run", action="store_true", default=False,
453+
help="Print payloads and endpoints that WOULD be sent without making real HTTP requests.",
454+
)
455+
425456

426457
# ── Auth flags ───────────────────────────────────────────────────────────
427458
auth_group = parser.add_argument_group("authentication")
@@ -559,24 +590,31 @@ def build_parser() -> argparse.ArgumentParser:
559590
p_enc.add_argument("--remove-original", action="store_true",
560591
help="Delete plaintext file after encryption.")
561592

593+
# nuclei-export
594+
p_nuclei = sub.add_parser("nuclei-export", help="Export findings as Nuclei v3 YAML templates.")
595+
p_nuclei.add_argument("report_file", help="JSON report file to export from.")
596+
p_nuclei.add_argument("output_dir", help="Directory to write Nuclei templates into.")
597+
562598
return parser
563599

564600

565601
_COMMAND_MAP = {
566-
"safe-audit": _cmd_safe_audit,
567-
"log-audit": _cmd_log_audit,
568-
"direct": _cmd_direct,
569-
"scan": _cmd_scan,
570-
"cve-scan": _cmd_cve_scan,
602+
"safe-audit": _cmd_safe_audit,
603+
"log-audit": _cmd_log_audit,
604+
"direct": _cmd_direct,
605+
"scan": _cmd_scan,
606+
"cve-scan": _cmd_cve_scan,
571607
"ssrf-scan": _cmd_ssrf_scan,
572-
"ssti-scan": _cmd_ssti_scan,
573-
"encrypt": _cmd_encrypt,
574-
"verify": _cmd_verify,
575-
"exploit": _cmd_exploit,
576-
"menu": _cmd_menu,
608+
"ssti-scan": _cmd_ssti_scan,
609+
"encrypt": _cmd_encrypt,
610+
"verify": _cmd_verify,
611+
"exploit": _cmd_exploit,
612+
"menu": _cmd_menu,
613+
"nuclei-export": _cmd_nuclei_export,
577614
}
578615

579616

617+
580618
# ---------------------------------------------------------------------------
581619
# Main entry point
582620
# ---------------------------------------------------------------------------
@@ -658,6 +696,16 @@ def _configure_subsystems(args: argparse.Namespace) -> None:
658696
except ImportError:
659697
pass
660698

699+
# Dry-run mode must be configured last (it overrides session creation)
700+
if getattr(args, "dry_run", False):
701+
try:
702+
from spring2shell.utils.dry_run import enable_dry_run
703+
enable_dry_run()
704+
print("[DRY-RUN] Mode enabled — no real HTTP requests will be sent.")
705+
except ImportError:
706+
pass
707+
708+
661709

662710
if __name__ == "__main__":
663711
main()

src/spring2shell/utils/burp_import.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,11 @@ def load_targets(path: str | Path) -> list[str]:
5959
def _parse_burp_xml(content: str) -> list[str]:
6060
"""Parse Burp Suite XML export — <items><item><url>…</url></item></items>."""
6161
urls: list[str] = []
62-
# Use regex to avoid requiring xml.etree on all platforms
62+
# Strip CDATA wrappers: <![CDATA[...]]> → content inside
63+
content = re.sub(r"<!\[CDATA\[(.*?)]]>", r"\1", content, flags=re.DOTALL)
6364
pattern = re.compile(r"<url>(.*?)</url>", re.DOTALL | re.IGNORECASE)
6465
for match in pattern.finditer(content):
6566
url = match.group(1).strip()
66-
# Burp sometimes CDATA-encodes or entity-encodes URLs
6767
url = url.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
6868
if url.startswith(("http://", "https://")):
6969
urls.append(url)

tests/unit/test_burp_import.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Unit tests for burp_import.py — B1: Burp Suite target import."""
2+
from __future__ import annotations
3+
4+
from pathlib import Path
5+
import pytest
6+
7+
from spring2shell.utils.burp_import import (
8+
_parse_burp_xml,
9+
_parse_plain_txt,
10+
_to_base_urls,
11+
load_targets,
12+
)
13+
14+
15+
_BURP_XML = """<?xml version="1.0" ?>
16+
<!DOCTYPE items [<!ENTITY xxe SYSTEM "">]>
17+
<items burpVersion="2023.1" exportTime="Mon Jan 01 00:00:00 UTC 2023">
18+
<item>
19+
<url><![CDATA[https://target.example/api/graphql?query=test]]></url>
20+
</item>
21+
<item>
22+
<url>https://target.example/actuator/env</url>
23+
</item>
24+
<item>
25+
<url>https://other.example:8443/login</url>
26+
</item>
27+
<item>
28+
<url>ftp://skip.example/file</url>
29+
</item>
30+
</items>
31+
"""
32+
33+
_PLAIN_TXT = """# My test targets
34+
https://alpha.example/api
35+
https://alpha.example/api/v2
36+
http://beta.example:8080/health
37+
# comment line
38+
invalid-line-no-scheme
39+
gamma.example
40+
"""
41+
42+
43+
class TestParseBurpXml:
44+
def test_extracts_https_urls(self):
45+
urls = _parse_burp_xml(_BURP_XML)
46+
assert any("target.example" in u for u in urls)
47+
assert any("other.example" in u for u in urls)
48+
49+
def test_skips_non_http_urls(self):
50+
urls = _parse_burp_xml(_BURP_XML)
51+
assert not any("ftp://" in u for u in urls)
52+
53+
def test_handles_cdata_urls(self):
54+
"""URLs inside CDATA sections should still be extracted."""
55+
xml = """<items>
56+
<item><url><![CDATA[https://target.example/api/graphql?query=test]]></url></item>
57+
</items>"""
58+
urls = _parse_burp_xml(xml)
59+
# CDATA markers are removed — the actual URL should be present
60+
assert any("graphql" in u and "target.example" in u for u in urls), \
61+
f"Expected URL with 'graphql' in parsed URLs, got: {urls}"
62+
63+
64+
65+
class TestParsePlainTxt:
66+
def test_returns_http_urls(self):
67+
urls = _parse_plain_txt(_PLAIN_TXT)
68+
assert "https://alpha.example/api" in urls
69+
assert "http://beta.example:8080/health" in urls
70+
71+
def test_skips_comments(self):
72+
urls = _parse_plain_txt(_PLAIN_TXT)
73+
assert not any(u.startswith("#") for u in urls)
74+
75+
def test_handles_bare_hostname(self):
76+
urls = _parse_plain_txt(_PLAIN_TXT)
77+
# gamma.example gets https:// prepended
78+
assert any("gamma.example" in u for u in urls)
79+
80+
def test_skips_invalid_lines(self):
81+
urls = _parse_plain_txt(_PLAIN_TXT)
82+
assert "invalid-line-no-scheme" not in urls
83+
84+
85+
class TestToBaseUrls:
86+
def test_deduplicates_same_host_different_paths(self):
87+
raw = [
88+
"https://example.com/api/v1",
89+
"https://example.com/api/v2",
90+
"https://example.com/actuator",
91+
]
92+
bases = _to_base_urls(raw)
93+
assert bases == ["https://example.com"]
94+
95+
def test_keeps_non_standard_port(self):
96+
raw = ["https://example.com:8443/api"]
97+
bases = _to_base_urls(raw)
98+
assert "https://example.com:8443" in bases
99+
100+
def test_strips_default_ports(self):
101+
raw = ["https://example.com:443/api", "http://example.com:80/api"]
102+
bases = _to_base_urls(raw)
103+
assert "https://example.com" in bases
104+
assert "http://example.com" in bases
105+
106+
def test_filters_non_http_urls(self):
107+
raw = ["ftp://example.com/file", "https://ok.example"]
108+
bases = _to_base_urls(raw)
109+
assert not any("ftp" in b for b in bases)
110+
111+
112+
class TestLoadTargets:
113+
def test_xml_file(self, tmp_path):
114+
xml_file = tmp_path / "burp.xml"
115+
xml_file.write_text(_BURP_XML)
116+
targets = load_targets(xml_file)
117+
assert len(targets) >= 2
118+
assert all(t.startswith(("http://", "https://")) for t in targets)
119+
120+
def test_plain_text_file(self, tmp_path):
121+
txt_file = tmp_path / "targets.txt"
122+
txt_file.write_text(_PLAIN_TXT)
123+
targets = load_targets(txt_file)
124+
assert len(targets) >= 2
125+
126+
def test_deduplication_across_full_load(self, tmp_path):
127+
txt_file = tmp_path / "dup_targets.txt"
128+
txt_file.write_text(
129+
"https://example.com/api\nhttps://example.com/login\nhttps://example.com/health\n"
130+
)
131+
targets = load_targets(txt_file)
132+
assert targets == ["https://example.com"]

tests/unit/test_dry_run.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Unit tests for dry_run.py — C3: dry-run mode."""
2+
from __future__ import annotations
3+
4+
import pytest
5+
6+
import spring2shell.utils.dry_run as dr_module
7+
from spring2shell.utils.dry_run import (
8+
DryRunSession,
9+
disable_dry_run,
10+
enable_dry_run,
11+
is_dry_run,
12+
)
13+
14+
15+
@pytest.fixture(autouse=True)
16+
def reset_dry_run():
17+
"""Always reset dry-run state between tests."""
18+
disable_dry_run()
19+
yield
20+
disable_dry_run()
21+
22+
23+
class TestDryRunFlag:
24+
def test_disabled_by_default(self):
25+
assert not is_dry_run()
26+
27+
def test_enable_sets_flag(self):
28+
enable_dry_run()
29+
assert is_dry_run()
30+
31+
def test_disable_clears_flag(self):
32+
enable_dry_run()
33+
disable_dry_run()
34+
assert not is_dry_run()
35+
36+
37+
class TestDryRunSession:
38+
def test_get_returns_fake_response(self):
39+
session = DryRunSession()
40+
resp = session.get("http://example.com/test")
41+
assert resp.status_code == 200
42+
assert resp.text == "DRY_RUN_RESPONSE"
43+
44+
def test_post_returns_fake_response(self):
45+
session = DryRunSession()
46+
resp = session.post("http://example.com/api", data='{"key": "value"}')
47+
assert resp.status_code == 200
48+
49+
def test_all_http_methods_work(self):
50+
session = DryRunSession()
51+
for method in [session.get, session.post, session.put,
52+
session.patch, session.delete, session.head]:
53+
resp = method("http://example.com/")
54+
assert resp.status_code == 200
55+
56+
def test_prints_method_and_url(self, capsys):
57+
session = DryRunSession()
58+
session.get("http://target.example/api/graphql")
59+
captured = capsys.readouterr()
60+
assert "[DRY-RUN]" in captured.out
61+
assert "GET" in captured.out
62+
assert "target.example" in captured.out
63+
64+
def test_prints_post_body(self, capsys):
65+
session = DryRunSession()
66+
session.post("http://example.com/api", data='{"query": "test"}')
67+
captured = capsys.readouterr()
68+
assert "query" in captured.out
69+
70+
def test_prints_headers(self, capsys):
71+
session = DryRunSession()
72+
session.get("http://example.com", headers={"Content-Type": "application/json"})
73+
captured = capsys.readouterr()
74+
assert "Content-Type" in captured.out
75+
76+
def test_redacts_authorization_header(self, capsys):
77+
session = DryRunSession()
78+
session.get("http://example.com", headers={"Authorization": "Bearer secret-token"})
79+
captured = capsys.readouterr()
80+
# Authorization header should NOT appear in output
81+
assert "secret-token" not in captured.out
82+
83+
def test_prints_query_params(self, capsys):
84+
session = DryRunSession()
85+
session.get("http://example.com/search", params={"q": "test", "page": "1"})
86+
captured = capsys.readouterr()
87+
assert "q=test" in captured.out or "q" in captured.out
88+
89+
def test_mount_does_not_crash(self):
90+
session = DryRunSession()
91+
session.mount("https://", None) # should not raise
92+
93+
def test_fake_response_json_returns_empty_dict(self):
94+
session = DryRunSession()
95+
resp = session.post("http://example.com")
96+
assert resp.json() == {}
97+
98+
def test_fake_response_raise_for_status_no_op(self):
99+
session = DryRunSession()
100+
resp = session.get("http://example.com")
101+
resp.raise_for_status() # should not raise
102+
103+
104+
class TestSessionIntegration:
105+
def test_create_stealth_session_returns_dry_run_session(self):
106+
"""When dry-run is active, create_stealth_session() returns DryRunSession."""
107+
enable_dry_run()
108+
from spring2shell.core.session import create_stealth_session
109+
session = create_stealth_session()
110+
assert isinstance(session, DryRunSession)

0 commit comments

Comments
 (0)