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/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/docs/chapters/configuration.texi b/docs/chapters/configuration.texi index 0a853d5..99037cb 100644 --- a/docs/chapters/configuration.texi +++ b/docs/chapters/configuration.texi @@ -488,6 +488,50 @@ 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; +@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 @subheading Your own profiles @@ -507,6 +551,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 +568,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/development.texi b/docs/chapters/development.texi index b69765b..64ea61e 100644 --- a/docs/chapters/development.texi +++ b/docs/chapters/development.texi @@ -163,11 +163,15 @@ 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, │ 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/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/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/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/data/profiles/security-pentest.toml b/src/codebud_sec/data/profiles/security-pentest.toml new file mode 100644 index 0000000..a6a95ae --- /dev/null +++ b/src/codebud_sec/data/profiles/security-pentest.toml @@ -0,0 +1,30 @@ +# 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). +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 +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/src/codebud_sec/scope.py b/src/codebud_sec/scope.py index d5f694b..08a421b 100644 --- a/src/codebud_sec/scope.py +++ b/src/codebud_sec/scope.py @@ -247,31 +247,133 @@ 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, 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", + "/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 +381,15 @@ 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 == "--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: - 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" @@ -326,11 +433,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: @@ -544,6 +666,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= 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_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_scope.py b/tests/test_scope.py index e53f387..5f70f9a 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): @@ -305,3 +341,100 @@ 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") + + +# --- 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 + + +# --- whois and masscan's rate (PR 5) ------------------------------------------------ + + +@pytest.mark.parametrize( + ("cmd", "hosts"), + [ + ("whois app.example.com", ["app.example.com"]), + ("whois -h whois.example.net 10.0.0.5", ["10.0.0.5", "whois.example.net"]), + ("whois AS64500", []), + ], +) +def test_whois_targets(cmd, hosts): + assert _hosts(cmd) == sorted(hosts) + assert _targets(cmd).tier == PASSIVE + + +def test_whois_out_of_scope_asks(guard): + engine = _engine() + engine.harmless_commands.append("whois") + step = _step("whois evil.example.org") + assert engine.refusal_reason(step) is None + assert "evil.example.org" in engine.confirmation_reason(step) + + +@pytest.mark.parametrize("cmd", ["whois", "whois a.example b.example", "whois -Z x.example"]) +def test_whois_that_cannot_be_read(cmd): + with pytest.raises(TargetError): + targets_of(shlex.split(cmd)) + + +@pytest.mark.parametrize("rate", ["1000", "100", "0.5"]) +def test_masscan_within_the_rate_cap(rate): + assert _nets(f"masscan -p80 --rate {rate} 10.0.2.0/24") == ["10.0.2.0/24"] + + +@pytest.mark.parametrize("rate", ["1001", "100000", "0", "-1", "fast", "inf", "nan"]) +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 diff --git a/tests/test_scope_web.py b/tests/test_scope_web.py index df14abf..621e886 100644 --- a/tests/test_scope_web.py +++ b/tests/test_scope_web.py @@ -63,7 +63,7 @@ def _hosts(cmd): [ ("curl -sS https://app.example.com/login", ["app.example.com"]), ("curl -sSo page.html http://10.0.0.5:8080/", ["10.0.0.5"]), - ("curl -XPOST -d @body.json https://user:pw@app.example.com/api", ["app.example.com"]), + ("curl -XPOST -d '{\"a\": 1}' https://user:pw@app.example.com/api", ["app.example.com"]), ("curl -g 'http://[2001:db8::5]:80/'", ["2001:db8::5"]), ("curl app.example.com", ["app.example.com"]), ( @@ -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') @@ -368,3 +368,68 @@ def test_feroxbuster_wordlist_is_judged_after_the_url_cleanup(project, line): def test_feroxbuster_wordlist_words_that_reach_no_host_are_fine(project): (project / "ok.txt").write_text("javascript:void(0)\nmailto:x\n/admin\nadmin\n") assert _hosts("feroxbuster -u https://app.example.com -w ok.txt") == ["app.example.com"] + + +# --- nothing of the project leaves for a target (PR 5) ------------------------------- + + +@pytest.mark.parametrize( + "cmd", + [ + "curl -T evidence.tar https://app.example.com/upload", + "curl --upload-file x https://app.example.com/", + "curl -d @secrets.txt https://app.example.com/", + "curl --data-binary @x https://app.example.com/", + "curl --json @x https://app.example.com/", + "curl --data-urlencode @x https://app.example.com/", + "curl --data-urlencode name@x https://app.example.com/", + "curl -F file=@x https://app.example.com/", + "curl -F 'notes=