From 8ca7b078cb6dc9ca5158f05ca95cbb129a243faf Mon Sep 17 00:00:00 2001 From: Mohammadreza Khellat Date: Thu, 12 Nov 2026 15:35:00 +0330 Subject: [PATCH 1/6] feat(profiles): a profile can require an open engagement Phase 10, step 5 plan, PR 5. The security-pentest profile's tools reach other hosts, and only the scope guard of an open engagement reads which; until one is open (the codebud-sec command, PR 7) such a profile must run no network tool at all. A profile key, engagement = true, refuses command and web search steps unless a registered step check provides "engagement". add_step_check() takes what a check provides, and the scope guard provides it while active. The core still knows nothing of the layer: it asks only whether some registered check provides the name. * src/codebud/profiles.py (_BOOL_KEYS): Add engagement. (Profile.engagement): New field. (Profile.summary, _load_file): Show and read it. * src/codebud/safety.py (_PROVIDES, provided, _engagement_refusal): New. (add_step_check): Take what the check provides. (remove_step_check): Forget it. (SafetyEngine.refusal_reason): Refuse without a required engagement. * src/codebud_sec/scope.py (ScopeGuard.activate): Provide "engagement". * tests/test_step_checks.py, tests/test_scope.py: Test them. * docs/chapters/configuration.texi (Profiles): Document the key. * docs/chapters/safety-and-sandbox.texi (The Safety Engine): And what a check provides. --- docs/chapters/configuration.texi | 6 ++++ docs/chapters/safety-and-sandbox.texi | 6 +++- src/codebud/profiles.py | 8 ++++- src/codebud/safety.py | 33 +++++++++++++++++- src/codebud_sec/scope.py | 2 +- tests/test_scope.py | 7 ++++ tests/test_step_checks.py | 50 +++++++++++++++++++++++++++ 7 files changed, 108 insertions(+), 4 deletions(-) diff --git a/docs/chapters/configuration.texi b/docs/chapters/configuration.texi index 0a853d5..5f10ddc 100644 --- a/docs/chapters/configuration.texi +++ b/docs/chapters/configuration.texi @@ -507,6 +507,7 @@ prompt = "Never modify files under tests/." encrypt = true # turn encryption at rest on integrity = true # refuse to start without a pinned manifest isolation = "observer" # steps see the host's processes (read-only) +engagement = true # commands only inside an open engagement @end example @noindent @@ -523,6 +524,11 @@ or level that does not exist, a missing policy directory --- is refused with a message rather than half applied. A project cannot define or choose a profile, since a profile can lower the privacy level. @code{isolation = "observer"} runs command steps with the host's processes in view, for live triage (@pxref{Isolated Steps}). +@code{engagement = true} refuses every command and web search step +unless an engagement is open, so that the security layer's scope check +reads the hosts each step reaches; reading and writing files still +works. It is for profiles whose tools reach other hosts, such as +@code{security-pentest}. @node Isolated Steps diff --git a/docs/chapters/safety-and-sandbox.texi b/docs/chapters/safety-and-sandbox.texi index 882a43e..77a21c1 100644 --- a/docs/chapters/safety-and-sandbox.texi +++ b/docs/chapters/safety-and-sandbox.texi @@ -226,7 +226,11 @@ registers a function that may refuse a step, and engine asks them after its own rules, for every step and again right before it runs, without knowing what they are; a check that fails with an error refuses the step. The security layer's engagement scope guard -is registered this way, so the core never imports that layer. +is registered this way, so the core never imports that layer. A check +can say what it @emph{provides} (@code{add_step_check(check, +provides="engagement")}), and a profile with @code{engagement = true} +refuses command and web search steps while no registered check provides +an engagement (@pxref{Profiles}). @code{describe_rules()} returns a compact summary of the three policy lists, which is embedded in the planner prompt so the model can avoid diff --git a/src/codebud/profiles.py b/src/codebud/profiles.py index 75db3e3..b6310ee 100644 --- a/src/codebud/profiles.py +++ b/src/codebud/profiles.py @@ -18,6 +18,9 @@ network namespaces -- for live triage (isolation.py) - integrity true refuses to start without a pinned integrity manifest (integrity.py); with one, it is checked whatever the profile +- engagement true refuses command and web search steps unless a step + check providing "engagement" is registered (safety.py) -- the + security layer's scope guard, when an engagement is open Built in: "default" (ask, private) and "unattended" (full-auto, trusted). The security layer, codebud_sec, ships more in its data/profiles/: they @@ -52,7 +55,7 @@ _NAME = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") _KEYS = ("description", "mode", "privacy", "policy", "prompt", "isolation") ISOLATIONS = ("default", "observer") -_BOOL_KEYS = ("encrypt", "integrity") +_BOOL_KEYS = ("encrypt", "integrity", "engagement") class ProfileError(Exception): @@ -71,10 +74,12 @@ class Profile: encrypt: bool = False # turn encryption at rest on integrity: bool = False # refuse to start without an integrity manifest isolation: str = "default" # or "observer" (isolation.py) + engagement: bool = False # steps need an open engagement (safety.py) def summary(self) -> str: extras = (", encrypted" if self.encrypt else "") + (", pinned" if self.integrity else "") extras += ", observer" if self.isolation == "observer" else "" + extras += ", engagement" if self.engagement else "" return f"{self.mode}, privacy {self.privacy}" + extras @@ -164,6 +169,7 @@ def _load_file(name: str, path: Path) -> Profile: data.get("encrypt", False), data.get("integrity", False), kind, + data.get("engagement", False), ) diff --git a/src/codebud/safety.py b/src/codebud/safety.py index 11ab634..811ee7c 100644 --- a/src/codebud/safety.py +++ b/src/codebud/safety.py @@ -478,15 +478,43 @@ def egress_reason(step: dict[str, Any]) -> str | None: # a notice returns why it must be confirmed (or None). _STEP_CHECKS: list = [] _STEP_NOTICES: list = [] +# What a registered check stands for ("engagement": the scope guard of an +# open engagement), so that a profile can require it (profiles.py). +_PROVIDES: dict = {} -def add_step_check(check) -> None: +def add_step_check(check, provides: str | None = None) -> None: _STEP_CHECKS.append(check) + if provides: + _PROVIDES[check] = provides def remove_step_check(check) -> None: if check in _STEP_CHECKS: _STEP_CHECKS.remove(check) + _PROVIDES.pop(check, None) + + +def provided(what: str) -> bool: + """Whether a registered step check provides ``what``.""" + return any(_PROVIDES.get(check) == what for check in _STEP_CHECKS) + + +def _engagement_refusal(step: dict[str, Any]) -> str | None: + """A profile with ``engagement = true`` runs no command or web search + unless an engagement's scope check is registered: its tools reach + other hosts, and only that check reads which.""" + from . import profiles + + profile = profiles.active() + if not profile.engagement or step.get("tool") not in ("command", "web_search"): + return None + if provided("engagement"): + return None + return ( + f"profile {profile.name} works only inside an open engagement, whose scope " + "check reads every target; open the engagement first" + ) def add_step_notice(notice) -> None: @@ -555,6 +583,9 @@ def refusal_reason(self, step: dict[str, Any]) -> str | None: tool = step.get("tool") if not step_allowed(self.mode, step): return f"{tool} is not allowed in {self.mode} mode" + engagement = _engagement_refusal(step) + if engagement: + return engagement if tool == "command" and step.get("args", {}).get("network"): if privacy.level() == "locked": return "steps that need the network are not allowed at privacy level locked" diff --git a/src/codebud_sec/scope.py b/src/codebud_sec/scope.py index d5f694b..92c753b 100644 --- a/src/codebud_sec/scope.py +++ b/src/codebud_sec/scope.py @@ -1063,7 +1063,7 @@ def notice(self, step): def activate(self) -> None: from codebud import safety - safety.add_step_check(self.refusal) + safety.add_step_check(self.refusal, provides="engagement") safety.add_step_notice(self.notice) def deactivate(self) -> None: diff --git a/tests/test_scope.py b/tests/test_scope.py index e53f387..7b5c372 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -305,3 +305,10 @@ def test_local_tools_are_untouched(guard): {"tool": "host_network", "args": {}}, ): assert _engine().refusal_reason(step) is None, step + + +def test_the_guard_provides_the_engagement(guard): + """A profile with engagement = true runs steps only while it is active.""" + assert safety.provided("engagement") + guard.deactivate() + assert not safety.provided("engagement") diff --git a/tests/test_step_checks.py b/tests/test_step_checks.py index f1c4424..7813300 100644 --- a/tests/test_step_checks.py +++ b/tests/test_step_checks.py @@ -71,3 +71,53 @@ def broken(step): safety.add_step_check(broken) reason = _engine().refusal_reason(_step("ls")) assert reason is not None and "check failed" in reason + + +# --- a profile that needs an engagement (step 5 plan, PR 5) ------------------------ + + +@pytest.fixture +def engagement_profile(monkeypatch): + from codebud import profiles + + monkeypatch.setattr(profiles, "_active", profiles.Profile("pentest", engagement=True)) + + +def test_an_engagement_profile_refuses_network_tools_without_one(engagement_profile): + """Without the scope check of an open engagement, nothing reads which + hosts a step reaches: commands and web searches are refused.""" + engine = _engine() + for step in (_step("nmap 10.0.0.1"), _step("ls"), {"tool": "web_search", "args": {}}): + reason = engine.refusal_reason(step) + assert reason is not None and "engagement" in reason, step + assert engine.refusal_reason({"tool": "file_read", "args": {"path": "notes.txt"}}) is None + + +def test_a_check_that_provides_the_engagement_lifts_it(engagement_profile): + def scope(step): + return None + + safety.add_step_check(scope, provides="engagement") + assert _engine().refusal_reason(_step("nmap 10.0.0.1")) is None + safety.remove_step_check(scope) + assert _engine().refusal_reason(_step("nmap 10.0.0.1")) is not None + + +def test_another_check_does_not_lift_it(engagement_profile): + safety.add_step_check(lambda step: None) + safety.add_step_check(lambda step: None, provides="something else") + assert _engine().refusal_reason(_step("nmap 10.0.0.1")) is not None + + +def test_other_profiles_need_no_engagement(): + assert _engine().refusal_reason(_step("nmap 10.0.0.1")) is None + + +def test_the_profile_key_is_read(tmp_path): + from codebud import profiles + + (tmp_path / "p.toml").write_text("engagement = true\n") + assert profiles._load_file("p", tmp_path / "p.toml").engagement is True + (tmp_path / "q.toml").write_text('engagement = "yes"\n') + with pytest.raises(profiles.ProfileError): + profiles._load_file("q", tmp_path / "q.toml") From 18c8ab68e2ef999659a1a1d250f67ee12e7057e3 Mon Sep 17 00:00:00 2001 From: Mohammadreza Khellat Date: Thu, 12 Nov 2026 15:36:13 +0330 Subject: [PATCH 2/6] feat(scope): judge nmap scripts by nmap's own script database Phase 10, step 5 plan, PR 5 (scanning tier, T1). Which scripts a --script expression selects is now worked out the way nmap does it -- names, categories, globs, all, and/or/not, commas -- from nmap's script.db, and the step is refused when any selected script is in a category beyond scanning (exploit, brute, dos, intrusive, fuzzer) or reaching past the command line (broadcast, external, targets-*). This replaces matching words of the expression, which refused "default and not broadcast"-style exclusions and could not see that the vuln category holds exploit and dos scripts. It also shows that nmap's own default category holds two external scripts (http-open-proxy, socks-open-proxy), so -sC and -A are refused with a hint: --script "default and not external". Script files run unchecked code and are refused; so are names nmap does not know. Without script.db no script can be judged, and --script, -sC and -A are refused. -sV stays allowed, though nmap marks one version script, iec61850-mms, intrusive; the code says so. * src/codebud_sec/scope.py (_NMAP_WIDE_CATEGORIES, _NMAP_WIDE_SAMPLES): Remove. (_NMAP_SCRIPT_DBS, _NMAP_REFUSED_CATEGORIES, _NMAP_ENTRY) (_nmap_scripts, _nmap_select): New. (_nmap_script_problem): Judge the scripts selected. (_nmap): Judge -sC and -A as default. * tests/test_scope.py (SCRIPT_DB, script_db): New fixture. (test_ordinary_scripts_run): -sC and default are refused now. (test_scripts_beyond_scanning_are_refused) (test_default_scripts_hold_external_ones) (test_without_nmaps_script_database_no_script_is_judged): New tests. * docs/chapters/development.texi: Describe them. --- docs/chapters/development.texi | 3 +- src/codebud_sec/scope.py | 142 ++++++++++++++++++++++++++++----- tests/test_scope.py | 82 ++++++++++++++++++- 3 files changed, 202 insertions(+), 25 deletions(-) diff --git a/docs/chapters/development.texi b/docs/chapters/development.texi index b69765b..a92d619 100644 --- a/docs/chapters/development.texi +++ b/docs/chapters/development.texi @@ -167,7 +167,8 @@ tests/ │ beside codebud, not imported, not shadowed ├── test_scope.py engagement scope: targets read per tool, │ third parties, ranges, active refused, -│ passive asked +│ passive asked; nmap scripts judged by +│ nmap's script.db ├── test_scope_web.py engagement scope for web and TLS tools: │ URL hosts read strictly, proxies, redirects │ and fuzz words in the host refused diff --git a/src/codebud_sec/scope.py b/src/codebud_sec/scope.py index 92c753b..2c2209a 100644 --- a/src/codebud_sec/scope.py +++ b/src/codebud_sec/scope.py @@ -247,31 +247,129 @@ def _host(args): r"^-p[0-9TUS:,\-]+$") # fmt: skip -# nmap scripts that reach past the command line: broadcast-* send to the -# whole local network (they are in the broadcast, discovery and partly -# safe categories), targets-* with newtargets add the hosts they find as -# targets, external ones contact third-party services. Selected by name, -# category, glob or boolean expression -- refused on any mention. -_NMAP_WIDE_CATEGORIES = {"broadcast", "discovery", "safe", "external", "all"} -_NMAP_WIDE_SAMPLES = ("broadcast-ping", "targets-sniffer", "whois-ip") +# nmap scripts (scanning tier, T1). Which scripts an expression selects +# is worked out from nmap's own script database, the way nmap does it: +# names, categories, globs, "all", and/or/not, commas. Refused when any +# selected script is in a category beyond scanning -- exploit, brute, dos, +# intrusive, fuzzer -- or reaches past the command line: broadcast +# (sends to the whole local network), external (contacts third parties; +# even "default" holds two, http-open-proxy and socks-open-proxy), and +# targets-* (adds the hosts it finds as targets). -sC and -A run +# "default". A script given as a file runs code nobody checked. Without +# the database no script can be judged, so --script, -sC and -A are +# refused. -sV runs the "version" scripts and is allowed, although nmap +# marks one of them, iec61850-mms (ICS, port 102), intrusive. +_NMAP_SCRIPT_DBS = ( + "/usr/share/nmap/scripts/script.db", + "/usr/local/share/nmap/scripts/script.db", + "/opt/homebrew/share/nmap/scripts/script.db", +) +_NMAP_REFUSED_CATEGORIES = frozenset( + {"exploit", "brute", "dos", "intrusive", "fuzzer", "broadcast", "external"} +) +_NMAP_ENTRY = re.compile(r'Entry \{ filename = "([^"]+)\.nse", categories = \{([^}]*)\} \}') -def _nmap_script_problem(expression: str) -> str | None: - for token in re.split(r"[\s,()]+", expression.lower()): - if not token or token in ("and", "or", "not", "default"): +def _nmap_scripts() -> dict[str, frozenset] | None: + """{script name: its categories} from nmap's script.db, or None.""" + for path in _NMAP_SCRIPT_DBS: + try: + with open(path, encoding="utf-8", errors="replace") as f: + text = f.read() + except OSError: continue - if token in _NMAP_WIDE_CATEGORIES: - return f"nmap --script {token} reaches hosts past the command line" - if token.startswith(("broadcast", "targets-")): - return f"nmap --script {token} reaches hosts past the command line" - if any(c in token for c in "*?[") and any( - fnmatch.fnmatch(sample, token) - for sample in (*_NMAP_WIDE_SAMPLES, *_NMAP_WIDE_CATEGORIES) - ): - return f"nmap --script {token} could select scripts that reach past the command line" + return { + name: frozenset(re.findall(r'"([^"]+)"', categories)) + for name, categories in _NMAP_ENTRY.findall(text) + } return None +def _nmap_select(expression: str, scripts: dict[str, frozenset]) -> set[str]: + """The scripts ``expression`` selects (nmap --script grammar).""" + tokens = re.findall(r"\(|\)|,|[^\s,()]+", expression) + position = 0 + + def peek(): + return tokens[position].lower() if position < len(tokens) else None + + def take(): + nonlocal position + position += 1 + return tokens[position - 1] + + def atom(word: str) -> set[str]: + name = word.lstrip("+").lower() + name = name[:-4] if name.endswith(".nse") else name + if "/" in name or "\\" in name: + raise TargetError( + f"nmap --script {word}: a script file runs code codebud has not checked" + ) + if name == "all": + return set(scripts) + if any(c in name for c in "*?["): + chosen = {s for s in scripts if fnmatch.fnmatch(s, name)} + else: + chosen = {s for s, categories in scripts.items() if name in categories} + chosen |= {name} if name in scripts else set() + if not chosen: + raise TargetError(f"nmap --script {word}: not a script or category nmap knows") + return chosen + + def factor() -> set[str]: + word = peek() + if word is None or word in (")", ",", "and", "or"): + raise TargetError(f"nmap --script {expression!r} cannot be read") + take() + if word == "not": + return set(scripts) - factor() + if word == "(": + chosen = union() + if peek() != ")": + raise TargetError(f"nmap --script {expression!r} cannot be read") + take() + return chosen + return atom(tokens[position - 1]) + + def conjunction() -> set[str]: + chosen = factor() + while peek() == "and": + take() + chosen &= factor() + return chosen + + def union() -> set[str]: + chosen = conjunction() + while peek() in ("or", ","): + take() + chosen |= conjunction() + return chosen + + chosen = union() + if position != len(tokens): + raise TargetError(f"nmap --script {expression!r} cannot be read") + return chosen + + +def _nmap_script_problem(expression: str) -> str | None: + scripts = _nmap_scripts() + if scripts is None: + return "nmap scripts are judged by nmap's script.db, which was not found" + beyond = sorted( + s + for s in _nmap_select(expression, scripts) + if scripts[s] & _NMAP_REFUSED_CATEGORIES or s.startswith(("broadcast", "targets-")) + ) + if not beyond: + return None + hint = ' (--script "default and not external" runs the rest)' if "default" in expression else "" + shown = ", ".join(beyond[:3]) + (f" and {len(beyond) - 3} more" if len(beyond) > 3 else "") + return ( + f"nmap --script {expression} selects scripts beyond scanning or past the command " + f"line ({shown}){hint}" + ) + + def _nmap(args): targets = Targets(ACTIVE) for option, word in _walk(args, _NMAP_VALUE, _NMAP_FLAGS, _NMAP_PATTERNS): @@ -279,10 +377,10 @@ def _nmap(args): raise TargetError("nmap -iR scans random hosts, outside any scope") if option in ("--resume", "--script-args-file"): raise TargetError(f"nmap {option} reads from a file that cannot be judged") - if option == "--script": - problem = _nmap_script_problem(word) + if option in ("--script", "-sC", "-A"): + problem = _nmap_script_problem(word if option == "--script" else "default") if problem: - raise TargetError(problem) + raise TargetError(problem.replace("--script default", option)) if option == "--script-args" and "newtargets" in word.lower(): raise TargetError( "nmap --script-args newtargets adds the hosts scripts find as targets" diff --git a/tests/test_scope.py b/tests/test_scope.py index 7b5c372..22ca931 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -24,6 +24,39 @@ """ +# A small nmap script.db with the categories nmap gives these scripts. +SCRIPT_DB = { + "http-title": ("default", "discovery", "safe"), + "ssl-cert": ("default", "discovery", "safe"), + "http-open-proxy": ("default", "discovery", "external", "safe"), + "banner": ("discovery", "safe"), + "broadcast-ping": ("broadcast", "discovery", "safe"), + "broadcast-dhcp-discover": ("broadcast", "safe"), + "targets-sniffer": ("broadcast", "discovery", "safe"), + "whois-ip": ("discovery", "external", "safe"), + "smb-vuln-ms17-010": ("vuln", "safe"), + "http-shellshock": ("exploit", "vuln", "intrusive"), + "ftp-brute": ("brute", "intrusive"), + "http-slowloris": ("dos", "intrusive", "vuln"), + "iec61850-mms": ("discovery", "intrusive", "version"), +} + + +@pytest.fixture(autouse=True) +def script_db(tmp_path, monkeypatch): + path = tmp_path / "script.db" + path.write_text( + "".join( + f'Entry {{ filename = "{name}.nse", categories = {{ ' + + "".join(f'"{c}", ' for c in categories) + + "} }\n" + for name, categories in SCRIPT_DB.items() + ) + ) + monkeypatch.setattr("codebud_sec.scope._NMAP_SCRIPT_DBS", (str(path),)) + return path + + @pytest.fixture def engagement(tmp_path): path = tmp_path / "acme.toml" @@ -283,9 +316,12 @@ def test_nmap_scripts_that_reach_past_the_command_line_are_refused(guard, cmd): @pytest.mark.parametrize( "cmd", [ - "nmap -sC 10.0.0.5", + "nmap -sV 10.0.0.5", "nmap --script http-title,ssl-cert 10.0.0.5", - "nmap --script default 10.0.0.5", + "nmap --script 'default and not external' 10.0.0.5", + "nmap --script smb-vuln-ms17-010 10.0.0.5", + "nmap --script '(vuln and safe) or banner' 10.0.0.5", + "nmap --script +http-title.nse 10.0.0.5", ], ) def test_ordinary_scripts_run(guard, cmd): @@ -312,3 +348,45 @@ def test_the_guard_provides_the_engagement(guard): assert safety.provided("engagement") guard.deactivate() assert not safety.provided("engagement") + + +# --- scripts judged by nmap's script database (PR 5) ----------------------------- + + +@pytest.mark.parametrize( + ("cmd", "why"), + [ + ("nmap --script http-shellshock 10.0.0.5", "exploit"), + ("nmap --script ftp-brute 10.0.0.5", "brute"), + ("nmap --script http-slowloris 10.0.0.5", "dos"), + ("nmap --script vuln 10.0.0.5", "the category holds exploit and dos scripts"), + ("nmap --script intrusive 10.0.0.5", "intrusive"), + ("nmap --script 'http-*' 10.0.0.5", "a glob holding exploits"), + ("nmap --script 'not safe' 10.0.0.5", "everything but safe"), + ("nmap --script 'vuln and not dos' 10.0.0.5", "still holds the exploit"), + ("nmap --script ./custom.nse 10.0.0.5", "a script file"), + ("nmap --script /tmp/x 10.0.0.5", "a script path"), + ("nmap --script nosuch 10.0.0.5", "unknown to nmap"), + ("nmap --script '(default' 10.0.0.5", "unreadable"), + ], +) +def test_scripts_beyond_scanning_are_refused(guard, cmd, why): + reason = _engine().refusal_reason(_step(cmd)) + assert reason is not None and "engagement scope" in reason, f"{cmd} ({why})" + + +@pytest.mark.parametrize("flag", ["-sC", "-A"]) +def test_default_scripts_hold_external_ones(guard, flag): + """nmap's own default category holds http-open-proxy and + socks-open-proxy, which nmap marks external.""" + reason = _engine().refusal_reason(_step(f"nmap {flag} 10.0.0.5")) + assert reason is not None and "http-open-proxy" in reason + assert "default and not external" in reason + + +def test_without_nmaps_script_database_no_script_is_judged(guard, monkeypatch): + monkeypatch.setattr("codebud_sec.scope._NMAP_SCRIPT_DBS", ("/nonexistent/script.db",)) + for cmd in ("nmap --script http-title 10.0.0.5", "nmap -sC 10.0.0.5"): + reason = _engine().refusal_reason(_step(cmd)) + assert reason is not None and "script.db" in reason, cmd + assert _engine().refusal_reason(_step("nmap -sV 10.0.0.5")) is None From fe9ce34108fd3ef8ea1cdc77fff5f495cd545424 Mon Sep 17 00:00:00 2001 From: Mohammadreza Khellat Date: Thu, 12 Nov 2026 15:37:20 +0330 Subject: [PATCH 3/6] feat(scope): whois, a masscan rate cap, and no files sent to targets Phase 10, step 5 plan, PR 5 (T0 and T1 rules). whois has a parser: one query, passive like a DNS lookup (out of scope it asks); a server given with -h is judged too; an AS number names no host. masscan's --rate is capped at 1000 packets a second (its default is 100), so a scan does not take the client's network down. curl and wget may not send a file's contents to a target -- curl -T, @file data, form fields and headers, wget --post-file and --body-file -- so files of the project (evidence, other engagements) never leave for a target; data written in the step itself is fine. * src/codebud_sec/scope.py (MASSCAN_MAX_RATE): New. (_masscan): Refuse a faster or unreadable --rate. (_curl_sends_a_file): New. (_curl, _wget): Refuse sending a file. (_WHOIS_VALUE, _WHOIS_FLAGS, _whois): New. (PARSERS): Add whois. * tests/test_scope.py, tests/test_scope_web.py: Test them. * docs/chapters/architecture.texi: Mention whois. --- docs/chapters/architecture.texi | 4 +-- src/codebud_sec/scope.py | 63 ++++++++++++++++++++++++++++++++- tests/test_scope.py | 41 +++++++++++++++++++++ tests/test_scope_web.py | 41 ++++++++++++++++++++- 4 files changed, 145 insertions(+), 4 deletions(-) diff --git a/docs/chapters/architecture.texi b/docs/chapters/architecture.texi index d50f343..69d6614 100644 --- a/docs/chapters/architecture.texi +++ b/docs/chapters/architecture.texi @@ -638,8 +638,8 @@ src/codebud/ the package src/codebud_sec/ the security layer (authorized security work) ├── engagement.py engagement file: authorized scope ├── scope.py scope guard: every network target read from the -│ command line and checked (DNS tools, scanners, -│ nc, web clients, fuzzers, TLS scanners); +│ command line and checked (DNS tools, whois, +│ scanners, nc, web clients, fuzzers, TLS scanners); │ registered with the core's safety.add_step_check() └── evidence.py hash-chained, append-only evidence log, encrypted at rest (appends need no passphrase) diff --git a/src/codebud_sec/scope.py b/src/codebud_sec/scope.py index 2c2209a..4d2d788 100644 --- a/src/codebud_sec/scope.py +++ b/src/codebud_sec/scope.py @@ -424,11 +424,26 @@ def _nmap(args): } +# masscan sends at --rate packets a second (100 unless given); faster than +# this risks the availability of the client's network and hosts. +MASSCAN_MAX_RATE = 1000 + + def _masscan(args): targets = Targets(ACTIVE) for option, word in _walk(args, _MASSCAN_VALUE, _MASSCAN_FLAGS, (r"^-p[0-9U:,\-]+$",)): if option in ("-c", "--conf", "--resume"): raise TargetError(f"masscan {option} reads settings, targets included, from a file") + if option == "--rate": + try: + rate = float(word) + except ValueError: + raise TargetError(f"masscan --rate {word}: not a number") from None + if not 0 < rate <= MASSCAN_MAX_RATE: + raise TargetError( + f"masscan --rate {word}: at most {MASSCAN_MAX_RATE} packets a second, " + "so the scan does not take the client's network down" + ) if option in ("-iL", "--includefile"): _list_file(word, targets) elif option == "--range" or option is None: @@ -642,6 +657,19 @@ def _need_targets(tool: str, targets: Targets) -> Targets: _CONNECT_TO = re.compile(r"^(\[[^\]]*\]|[^:]*):([^:]*):(\[[^\]]*\]|[^:]*):(.*)$") +def _curl_sends_a_file(option, word) -> bool: + """Whether ``option word`` makes curl send the contents of a file.""" + if option in ("-T", "--upload-file"): + return True + if option in ("-d", "--data", "--data-binary", "--json", "-H", "--header"): + return word.startswith("@") + if option == "--data-urlencode": # @file or name@file + return bool(re.match(r"^[^=]*@", word)) + if option in ("-F", "--form"): # name=@file or name= Date: Thu, 12 Nov 2026 15:42:00 +0330 Subject: [PATCH 4/6] feat(profiles): ship security-pentest, reconnaissance and scanning Phase 10, step 5 plan, PR 5: tiers T0 (lookups) and T1 (scanning) of the accepted plan (D14i); exploitation (T2) waits for the scope fence. The profile runs no command and no web search until an engagement is open (engagement = true); the engagement's scope guard then reads every host each network step contacts and refuses what is out of scope. Mode ask; privacy private, since locked refuses network steps; encrypted; integrity required. Reading results is harmless; every network tool is powerful and asked each time: dig, host, whois, nmap, masscan, nc, ncat, curl, wget, whatweb, nikto, gobuster, ffuf, feroxbuster, sslscan, testssl.sh. The profile refuses listeners (nc -l, ncat --listen, --broker, --chat), which catch shells. Its prompt states the rules the guard enforces, so plans meet them the first time. * src/codebud_sec/data/profiles/security-pentest.toml: New profile. * src/codebud_sec/data/profiles/security-pentest/: Its policy files. * src/Makefile.am (nobase_dist_python_DATA): Install them. * tests/test_security_pentest.py: New file. * tests/test_profiles.py (test_builtins): List it. * docs/chapters/configuration.texi (Profiles): Document it. * docs/chapters/development.texi: List the test file. * NEWS: Mention it. --- NEWS | 10 ++ docs/chapters/configuration.texi | 41 ++++++ docs/chapters/development.texi | 3 + src/Makefile.am | 7 +- .../data/profiles/security-pentest.toml | 29 ++++ .../security-pentest/harmless_commands.json | 5 + .../security-pentest/powerful_commands.json | 5 + .../security-pentest/refused_arguments.json | 23 ++++ .../security-pentest/trusted_sequences.json | 1 + tests/test_profiles.py | 2 +- tests/test_security_pentest.py | 124 ++++++++++++++++++ 11 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 src/codebud_sec/data/profiles/security-pentest.toml create mode 100644 src/codebud_sec/data/profiles/security-pentest/harmless_commands.json create mode 100644 src/codebud_sec/data/profiles/security-pentest/powerful_commands.json create mode 100644 src/codebud_sec/data/profiles/security-pentest/refused_arguments.json create mode 100644 src/codebud_sec/data/profiles/security-pentest/trusted_sequences.json create mode 100644 tests/test_security_pentest.py diff --git a/NEWS b/NEWS index eea6a52..c35c201 100644 --- a/NEWS +++ b/NEWS @@ -75,6 +75,16 @@ a safety policy you can read and change. Imaging tools work on image files in the project only, hashed and logged (dc3dd, ewfacquire), and exiftool may only read. + security-pentest, reconnaissance and scanning inside an engagement: + it runs no command until an engagement is open, and the engagement's + scope guard reads every target of dig, host, whois, nmap, masscan, + nc, curl, wget, whatweb, nikto, gobuster, ffuf, feroxbuster, sslscan + and testssl.sh -- out of scope is refused. nmap scripts are judged by + nmap's own script database (no exploit, brute, dos, intrusive or + external ones); redirects are never followed; no listeners, no + exploitation tools, no files sent to a target. A profile requires an + engagement with engagement = true. + A profile can ask for observer isolation (isolation = "observer"): command steps see the host's processes through a read-only /proc, for live triage, but still cannot signal them or reach the network. diff --git a/docs/chapters/configuration.texi b/docs/chapters/configuration.texi index 5f10ddc..5e8a772 100644 --- a/docs/chapters/configuration.texi +++ b/docs/chapters/configuration.texi @@ -488,6 +488,47 @@ done by you, outside Codebud, with a write-blocker where it applies (NIST SP 800-86 advises against imaging a whole device from a live system); Codebud analyses the images, and re-images or converts them (raw to E01) only as files inside the project. + +@item security-pentest +Reconnaissance and scanning of hosts an engagement authorizes, the +first two tiers of a penetration test (NIST SP 800-115): no +exploitation, no password guessing, no denial of service. Its +settings: mode @code{ask}; privacy @code{private} (@code{locked} would +refuse every network step); encryption at rest on; an integrity +manifest required; and @code{engagement = true}, so it runs no command +and no web search until an engagement is open. The engagement's scope +guard then reads every host a network step will contact --- targets, +proxies, DNS servers, addresses given to @command{curl --resolve} --- +and refuses the step when one is out of scope, excluded, or the +engagement window is closed. A command it cannot read (an unknown +option, a target list outside the project, an ambiguous URL) is refused +too. + +Its allow-list: reading results runs once the plan is approved +(@command{cat}, @command{grep}, @command{rg}, @command{jq}, +@command{diff}, hashes, @dots{}); every network tool is powerful and +asked for every time --- @command{dig}, @command{host}, @command{whois}, +@command{nmap}, @command{masscan}, @command{nc}, @command{ncat}, +@command{curl}, @command{wget}, @command{whatweb}, @command{nikto}, +@command{gobuster}, @command{ffuf}, @command{feroxbuster}, +@command{sslscan} and @command{testssl.sh}. + +What the scope guard refuses beyond out-of-scope targets: nmap scripts +that nmap's own database marks exploit, brute, dos, intrusive, fuzzer, +broadcast or external, worked out from the whole @option{--script} +expression --- nmap's @code{default} category holds two external +scripts, so @option{-sC} and @option{-A} are refused; use +@code{--script "default and not external"} (@option{-sV} is allowed, +though nmap marks one version script, @code{iec61850-mms}, intrusive); +following redirects (@command{curl -L}; @command{wget} needs +@option{--max-redirect=0} and @command{whatweb} +@option{--follow-redirect=never}); a fuzzing keyword anywhere but the +path; @command{masscan} faster than 1000 packets a second; sending a +file to a target (@command{curl -T}, @code{-d @@file}, @command{wget +--post-file}); environment assignments in front of a network tool +(@code{env HTTPS_PROXY=@dots{}}). The profile itself refuses +listeners (@command{nc -l}, @command{ncat --listen}), which catch +shells. @end table @subheading Your own profiles diff --git a/docs/chapters/development.texi b/docs/chapters/development.texi index a92d619..64ea61e 100644 --- a/docs/chapters/development.texi +++ b/docs/chapters/development.texi @@ -163,6 +163,9 @@ tests/ │ egress rule, bad entries, sources ├── test_security_ir.py security-ir: settings, what triage may run, │ what would change the host is refused +├── test_security_pentest.py security-pentest: nothing without an +│ engagement; recon in scope runs, beyond +│ scanning is refused ├── test_shipped_profiles.py profiles of codebud_sec: found by name │ beside codebud, not imported, not shadowed ├── test_scope.py engagement scope: targets read per tool, diff --git a/src/Makefile.am b/src/Makefile.am index ef2acd7..3687ac6 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -75,7 +75,12 @@ nobase_dist_python_DATA = \ codebud_sec/data/profiles/security-ir/harmless_commands.json \ codebud_sec/data/profiles/security-ir/powerful_commands.json \ codebud_sec/data/profiles/security-ir/refused_arguments.json \ - codebud_sec/data/profiles/security-ir/trusted_sequences.json + codebud_sec/data/profiles/security-ir/trusted_sequences.json \ + codebud_sec/data/profiles/security-pentest.toml \ + codebud_sec/data/profiles/security-pentest/harmless_commands.json \ + codebud_sec/data/profiles/security-pentest/powerful_commands.json \ + codebud_sec/data/profiles/security-pentest/refused_arguments.json \ + codebud_sec/data/profiles/security-pentest/trusted_sequences.json # A minimal dist-info directory, so importlib.metadata (and therefore # `codebud version`) knows the installed version without pip. diff --git a/src/codebud_sec/data/profiles/security-pentest.toml b/src/codebud_sec/data/profiles/security-pentest.toml new file mode 100644 index 0000000..2fae660 --- /dev/null +++ b/src/codebud_sec/data/profiles/security-pentest.toml @@ -0,0 +1,29 @@ +# security-pentest: reconnaissance and scanning (tiers T0 and T1 of the +# step 5 plan) of hosts an engagement authorizes. Every command runs only +# while an engagement is open, and its scope guard reads every target a +# network step names: out of scope, excluded or outside the window is +# refused. Exploitation (T2) is not part of this profile. See "Security +# profiles" in the manual and, for the reasoning, NIST SP 800-115 and +# the PTES rules of engagement. +description = "Reconnaissance and scanning inside an engagement's scope; no exploitation" +mode = "ask" +privacy = "private" +encrypt = true +integrity = true +engagement = true +policy = "security-pentest" +prompt = """ +This is an authorized penetration test. Every target must be in the +engagement's scope; a step naming any other host is refused, so never guess +or widen targets. Declare the network ("network": true) on every step that +contacts a host. +Reconnaissance and scanning only: no exploitation, no password guessing, no +denial of service. For nmap use -sV and named scripts, or --script +"default and not external" (plain -sC is refused). masscan at --rate 1000 +or less. Never follow redirects: curl without -L, wget --max-redirect=0, +whatweb --follow-redirect=never. Fuzz only the path (https://host/FUZZ). +Never send files to a target. +Write every output into the project (for example scans/ and notes/) with +the tool's own output option (-oA, -o, --jsonfile) so the work can be +reported, and hash what you keep with sha256sum. +""" diff --git a/src/codebud_sec/data/profiles/security-pentest/harmless_commands.json b/src/codebud_sec/data/profiles/security-pentest/harmless_commands.json new file mode 100644 index 0000000..4a065ab --- /dev/null +++ b/src/codebud_sec/data/profiles/security-pentest/harmless_commands.json @@ -0,0 +1,5 @@ +[ + "cat", "head", "tail", "ls", "stat", "file", "wc", "sort", "uniq", "cut", + "diff", "cmp", "tr", "grep", "rg", "jq", "xxd", "strings", "base64", + "sha256sum", "sha1sum", "md5sum", "find", "pwd", "echo", "date" +] diff --git a/src/codebud_sec/data/profiles/security-pentest/powerful_commands.json b/src/codebud_sec/data/profiles/security-pentest/powerful_commands.json new file mode 100644 index 0000000..9ea7877 --- /dev/null +++ b/src/codebud_sec/data/profiles/security-pentest/powerful_commands.json @@ -0,0 +1,5 @@ +[ + "dig", "host", "whois", "nmap", "masscan", "nc", "ncat", "curl", "wget", + "whatweb", "nikto", "gobuster", "ffuf", "feroxbuster", "sslscan", + "testssl.sh", "testssl", "timeout", "mkdir" +] diff --git a/src/codebud_sec/data/profiles/security-pentest/refused_arguments.json b/src/codebud_sec/data/profiles/security-pentest/refused_arguments.json new file mode 100644 index 0000000..b7b4059 --- /dev/null +++ b/src/codebud_sec/data/profiles/security-pentest/refused_arguments.json @@ -0,0 +1,23 @@ +{ + "nc": [ + "-l" + ], + "ncat": [ + "-l", + "--listen", + "--broker", + "--chat" + ], + "sort": [ + "-o", + "--output" + ], + "rg": [ + "--pre", + "--pre-glob" + ], + "date": [ + "-s", + "--set" + ] +} diff --git a/src/codebud_sec/data/profiles/security-pentest/trusted_sequences.json b/src/codebud_sec/data/profiles/security-pentest/trusted_sequences.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/src/codebud_sec/data/profiles/security-pentest/trusted_sequences.json @@ -0,0 +1 @@ +[] diff --git a/tests/test_profiles.py b/tests/test_profiles.py index c8633d6..d717e02 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -28,7 +28,7 @@ def test_builtins(): assert profiles.get("default").summary() == "ask, privacy private" assert profiles.get("unattended").summary() == "full-auto, privacy trusted" # The built-ins, and the security layer's shipped profiles. - assert profiles.available() == ["default", "security-ir", "unattended"] + assert profiles.available() == ["default", "security-ir", "security-pentest", "unattended"] def test_user_profile_is_loaded_with_its_policy_and_prompt(tmp_path): diff --git a/tests/test_security_pentest.py b/tests/test_security_pentest.py new file mode 100644 index 0000000..60a120d --- /dev/null +++ b/tests/test_security_pentest.py @@ -0,0 +1,124 @@ +""" +tests/test_security_pentest.py + +The security-pentest profile shipped with codebud_sec (phase 10, step 5 +plan, PR 5; tiers T0 and T1): reconnaissance and scanning, only while an +engagement is open, every target read and judged by its scope guard; no +exploitation, no listeners, nothing of the project sent to a target. +""" + +import logging + +import pytest + +from codebud import paths, profiles, safety +from codebud.safety import SafetyEngine +from codebud_sec.engagement import load +from codebud_sec.scope import ScopeGuard + +SCOPE = """\ +client = "ACME" +in_scope = ["10.0.0.0/16", "app.example.com"] +out_of_scope = ["10.0.5.0/24"] +""" + + +@pytest.fixture +def pentest(monkeypatch): + profile = profiles.get("security-pentest") + monkeypatch.setattr(profiles, "_active", profile) + monkeypatch.setattr(paths, "PROFILE_POLICY_DIR", profile.policy) + monkeypatch.setattr(safety, "_STEP_CHECKS", []) + monkeypatch.setattr(safety, "_STEP_NOTICES", []) + monkeypatch.delenv("CODEBUD_PRIVACY", raising=False) + monkeypatch.setattr("codebud.isolation.available", lambda: "bwrap") + return profile + + +@pytest.fixture +def engagement(pentest, tmp_path, monkeypatch): + path = tmp_path / "acme.toml" + path.write_text(SCOPE) + guard = ScopeGuard(load(path)) + guard.activate() + monkeypatch.chdir(tmp_path) + return guard + + +def _step(cmd, network=True): + return {"tool": "command", "args": {"cmd": cmd, "network": network}} + + +def test_it_ships_and_is_found(): + assert "security-pentest" in profiles.available() + profile = profiles.get("security-pentest") + assert (profile.mode, profile.privacy, profile.isolation) == ("ask", "private", "default") + assert profile.encrypt and profile.integrity and profile.engagement + assert "default and not external" in profile.prompt + assert profile.source.endswith("codebud_sec/data/profiles/security-pentest.toml") + + +def test_its_lists_load_without_a_dropped_entry(pentest, caplog): + with caplog.at_level(logging.WARNING): + engine = SafetyEngine() + assert "Ignoring" not in caplog.text + assert "jq" in engine.harmless_commands and "nmap" in engine.powerful_commands + assert engine.trusted_sequences == [] + + +def test_without_an_engagement_nothing_runs(pentest): + for step in (_step("nmap 10.0.0.5"), _step("ls", network=False)): + reason = SafetyEngine().refusal_reason(step) + assert reason is not None and "engagement" in reason, step + + +@pytest.mark.parametrize( + "cmd", + [ + "dig app.example.com", + "whois app.example.com", + "nmap -sV -p 22,80,443 -oA scans/web 10.0.0.5", + "masscan -p1-1000 --rate 500 10.0.1.0/24", + "curl -sS https://app.example.com/", + "wget --max-redirect=0 -O scans/index.html https://app.example.com/", + "whatweb --follow-redirect=never https://app.example.com", + "gobuster dir -u https://app.example.com/ -w words.txt -o scans/dirs.txt", + "ffuf -u https://app.example.com/FUZZ -w words.txt -o scans/ffuf.json", + "testssl.sh --jsonfile scans/tls.json app.example.com", + "nc -zv 10.0.0.5 22", + "timeout 600 nmap -sV 10.0.0.5", + ], +) +def test_recon_and_scanning_in_scope_run(engagement, cmd): + assert SafetyEngine().refusal_reason(_step(cmd)) is None, cmd + + +@pytest.mark.parametrize( + "cmd", ["sha256sum scans/web.xml", "jq . scans/ffuf.json", "grep -c open scans/web.gnmap"] +) +def test_reading_results_needs_no_network(engagement, cmd): + assert SafetyEngine().refusal_reason(_step(cmd, network=False)) is None, cmd + + +@pytest.mark.parametrize( + ("cmd", "why"), + [ + ("nmap 192.168.1.1", "out of scope"), + ("nmap 10.0.5.7", "excluded"), + ("nc -lvnp 4444", "a listener catches shells: exploitation"), + ("ncat --listen 4444", "a listener"), + ("ncat -e /bin/sh 10.0.0.5 4444", "a shell"), + ("curl -T scans/web.xml https://app.example.com/", "sends a file"), + ("masscan -p80 --rate 100000 10.0.1.0/24", "too fast"), + ("hydra -l admin -P words.txt ssh://10.0.0.5", "password guessing: not allowed"), + ("sqlmap -u https://app.example.com/?id=1", "exploitation: not allowed"), + ("rm -rf scans", "not allowed"), + ], +) +def test_what_goes_beyond_scanning_is_refused(engagement, cmd, why): + assert SafetyEngine().refusal_reason(_step(cmd)) is not None, f"{cmd} ({why})" + + +def test_network_tools_are_asked_every_time(engagement): + engine = SafetyEngine() + assert engine.confirmation_reason(_step("nmap -sV 10.0.0.5")) is not None From 353a7ea4be9f9aa00f0075f49cb0323265ba6c8f Mon Sep 17 00:00:00 2001 From: Mohammadreza Khellat Date: Thu, 12 Nov 2026 15:44:05 +0330 Subject: [PATCH 5/6] fix(scope): refuse nmap --datadir Review of #21. nmap --datadir loads scripts and script.db from the directory given, which a step can write: an innocuously named script there runs any Lua while the guard judges the system's database. * src/codebud_sec/scope.py (_nmap): Refuse --datadir. Say why, and what --script-args can still name (a DNS domain for the resolver). * tests/test_scope.py (test_nmap_datadir_is_refused): New test. --- src/codebud_sec/scope.py | 13 +++++++++++-- tests/test_scope.py | 7 +++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/codebud_sec/scope.py b/src/codebud_sec/scope.py index 4d2d788..50c3b85 100644 --- a/src/codebud_sec/scope.py +++ b/src/codebud_sec/scope.py @@ -257,8 +257,12 @@ def _host(args): # targets-* (adds the hosts it finds as targets). -sC and -A run # "default". A script given as a file runs code nobody checked. Without # the database no script can be judged, so --script, -sC and -A are -# refused. -sV runs the "version" scripts and is allowed, although nmap -# marks one of them, iec61850-mms (ICS, port 102), intrusive. +# refused, and so is --datadir, which would load scripts and their +# database from elsewhere. -sV runs the "version" scripts and is allowed, +# although nmap marks one of them, iec61850-mms (ICS, port 102), +# intrusive. A few discovery scripts take a DNS domain in --script-args +# (dns-srv-enum.domain); those queries go to the resolver, as passive +# lookups do. _NMAP_SCRIPT_DBS = ( "/usr/share/nmap/scripts/script.db", "/usr/local/share/nmap/scripts/script.db", @@ -377,6 +381,11 @@ def _nmap(args): raise TargetError("nmap -iR scans random hosts, outside any scope") if option in ("--resume", "--script-args-file"): raise TargetError(f"nmap {option} reads from a file that cannot be judged") + if option == "--datadir": + raise TargetError( + "nmap --datadir loads scripts and their database from a directory " + "steps can write, so its scripts cannot be judged" + ) if option in ("--script", "-sC", "-A"): problem = _nmap_script_problem(word if option == "--script" else "default") if problem: diff --git a/tests/test_scope.py b/tests/test_scope.py index b71e964..5f70f9a 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -431,3 +431,10 @@ def test_masscan_within_the_rate_cap(rate): def test_masscan_over_the_rate_cap_is_refused(rate): with pytest.raises(TargetError, match="rate"): targets_of(shlex.split(f"masscan -p80 --rate {rate} 10.0.2.0/24")) + + +def test_nmap_datadir_is_refused(guard): + """Review of #21: scripts and script.db would come from a directory a + step can write, while the guard judges the system's database.""" + reason = _engine().refusal_reason(_step("nmap --datadir ./mine --script http-title 10.0.0.5")) + assert reason is not None and "--datadir" in reason From f1c22d0fe42ada47f515249dc02d7bfce0b04476 Mon Sep 17 00:00:00 2001 From: Mohammadreza Khellat Date: Thu, 12 Nov 2026 15:44:41 +0330 Subject: [PATCH 6/6] fix(scope): no file bodies from feroxbuster, no nikto DoS tests Review of #21. feroxbuster --data @file sends the file as the request body, as curl does; it is refused like curl's. nikto's tuning class 6 is its denial-of-service tests, outside the scanning tier: nikto now needs -Tuning leaving it out (x6, or a list of classes without 6). The profile's prompt and the manual say so. * src/codebud_sec/scope.py (_feroxbuster): Refuse --data @file. (_nikto): Require -Tuning without class 6. * src/codebud_sec/data/profiles/security-pentest.toml (prompt): Ask for -Tuning x6. * tests/test_scope_web.py: Give nikto -Tuning x6. (test_feroxbuster_data_from_a_file_is_refused) (test_nikto_without_leaving_out_dos_is_refused) (test_nikto_leaving_out_dos_runs): New tests. * docs/chapters/configuration.texi (Profiles): Document them and nmap --datadir. --- docs/chapters/configuration.texi | 11 ++++--- .../data/profiles/security-pentest.toml | 1 + src/codebud_sec/scope.py | 14 +++++++++ tests/test_scope_web.py | 30 +++++++++++++++++-- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/docs/chapters/configuration.texi b/docs/chapters/configuration.texi index 5e8a772..99037cb 100644 --- a/docs/chapters/configuration.texi +++ b/docs/chapters/configuration.texi @@ -523,10 +523,13 @@ though nmap marks one version script, @code{iec61850-mms}, intrusive); following redirects (@command{curl -L}; @command{wget} needs @option{--max-redirect=0} and @command{whatweb} @option{--follow-redirect=never}); a fuzzing keyword anywhere but the -path; @command{masscan} faster than 1000 packets a second; sending a -file to a target (@command{curl -T}, @code{-d @@file}, @command{wget ---post-file}); environment assignments in front of a network tool -(@code{env HTTPS_PROXY=@dots{}}). The profile itself refuses +path; @command{masscan} faster than 1000 packets a second; +@command{nikto} without @option{-Tuning} leaving out class 6, its +denial-of-service tests (@code{-Tuning x6}); @command{nmap +--datadir}, which would load scripts from elsewhere; sending a file to +a target (@command{curl -T}, @code{-d @@file}, @command{wget +--post-file}, @command{feroxbuster --data @@file}); environment +assignments in front of a network tool (@code{env HTTPS_PROXY=@dots{}}). The profile itself refuses listeners (@command{nc -l}, @command{ncat --listen}), which catch shells. @end table diff --git a/src/codebud_sec/data/profiles/security-pentest.toml b/src/codebud_sec/data/profiles/security-pentest.toml index 2fae660..a6a95ae 100644 --- a/src/codebud_sec/data/profiles/security-pentest.toml +++ b/src/codebud_sec/data/profiles/security-pentest.toml @@ -22,6 +22,7 @@ denial of service. For nmap use -sV and named scripts, or --script "default and not external" (plain -sC is refused). masscan at --rate 1000 or less. Never follow redirects: curl without -L, wget --max-redirect=0, whatweb --follow-redirect=never. Fuzz only the path (https://host/FUZZ). +nikto always with -Tuning x6 (its denial-of-service tests left out). Never send files to a target. Write every output into the project (for example scans/ and notes/) with the tool's own output option (-oA, -o, --jsonfile) so the work can be diff --git a/src/codebud_sec/scope.py b/src/codebud_sec/scope.py index 50c3b85..08a421b 100644 --- a/src/codebud_sec/scope.py +++ b/src/codebud_sec/scope.py @@ -831,8 +831,11 @@ def _nikto(args): value = {d + o for o in _NIKTO_VALUE for d in ("-", "--")} flags = {d + o for o in _NIKTO_FLAGS for d in ("-", "--")} _no_local_config("nikto", "nikto.conf") + tuning = None for option, word in _walk(args, value, flags): name = (option or "").lstrip("-") + if name == "Tuning": + tuning = word if name == "followredirects": raise TargetError("nikto -followredirects goes to hosts the server chooses") if name == "config": @@ -849,6 +852,12 @@ def _nikto(args): targets.add(_host_port(host, "target")) elif option is None: raise TargetError(f"nikto {word}: targets are given with -h") + # Tuning class 6 is nikto's denial-of-service tests; x inverts the list. + if tuning is None or (("6" in tuning) != tuning.startswith("x")): + raise TargetError( + "nikto must leave out its denial-of-service tests (tuning 6): " + "give -Tuning x6, or a list of classes without 6" + ) return _need_targets("nikto", targets) @@ -995,6 +1004,11 @@ def _feroxbuster(args): raise TargetError("feroxbuster -r follows redirects to hosts the server chooses") if option in ("--stdin", "--resume-from"): raise TargetError(f"feroxbuster {option} takes targets from elsewhere") + if option == "--data" and word.startswith("@"): + raise TargetError( + f"feroxbuster --data {word}: sends a file to the target; files of the " + "project (evidence, other engagements) do not leave for a target" + ) if option in ("-w", "--wordlist"): wordlist = word elif option in ("-p", "--proxy", "-P", "--replay-proxy"): diff --git a/tests/test_scope_web.py b/tests/test_scope_web.py index 54a4e82..621e886 100644 --- a/tests/test_scope_web.py +++ b/tests/test_scope_web.py @@ -94,7 +94,7 @@ def _hosts(cmd): ["10.0.0.5", "app.example.com"], ), ("nikto -h https://app.example.com -Tuning 123 -nointeractive", ["app.example.com"]), - ("nikto -h 10.0.0.5 -p 8443 -ssl", ["10.0.0.5"]), + ("nikto -h 10.0.0.5 -p 8443 -ssl -Tuning x6", ["10.0.0.5"]), ("gobuster dir -u https://app.example.com/ -w words.txt -x php -t 20", ["app.example.com"]), ( "gobuster dir -u https://app.example.com/ -w words.txt -d -c session=1", @@ -217,7 +217,7 @@ def test_a_config_file_in_the_project_refuses_the_tool(project, tool, config): """A step could write it, adding a proxy or targets codebud never sees.""" cmd = { "feroxbuster": "feroxbuster -u https://app.example.com -w words.txt", - "nikto": "nikto -h app.example.com", + "nikto": "nikto -h app.example.com -Tuning x6", }[tool] assert _hosts(cmd) == ["app.example.com"] (project / config).write_text('proxy = "http://evil.example.org:8080"\n') @@ -407,3 +407,29 @@ def test_sending_a_file_to_a_target_is_refused(project, cmd): ) def test_data_written_in_the_step_is_fine(project, cmd): assert _hosts(cmd) == ["app.example.com"] + + +# --- review of #21 ----------------------------------------------------------------- + + +def test_feroxbuster_data_from_a_file_is_refused(project): + with pytest.raises(TargetError, match="sends a file"): + targets_of( + shlex.split("feroxbuster -u https://app.example.com -w words.txt --data @secrets.txt") + ) + assert _hosts("feroxbuster -u https://app.example.com -w words.txt --data a=1") == [ + "app.example.com" + ] + + +@pytest.mark.parametrize("tuning", [None, "6", "126", "x12", "x"]) +def test_nikto_without_leaving_out_dos_is_refused(project, tuning): + """Review of #21: tuning class 6 is nikto's denial-of-service tests.""" + cmd = "nikto -h app.example.com" + (f" -Tuning {tuning}" if tuning else "") + with pytest.raises(TargetError, match="denial-of-service"): + targets_of(shlex.split(cmd)) + + +@pytest.mark.parametrize("tuning", ["x6", "x16", "123b", "0"]) +def test_nikto_leaving_out_dos_runs(project, tuning): + assert _hosts(f"nikto -h app.example.com -Tuning {tuning}") == ["app.example.com"]