diff --git a/commands.py b/commands.py index 9f257c2..e47242e 100644 --- a/commands.py +++ b/commands.py @@ -568,6 +568,38 @@ def _gh_option(words: list[str], flag: str, short: str = "", boolean: bool = Fal return value.lower() +def _starts_automatically(prog: str, lower: list[str]) -> str | None: + """Changes that schedule work after this command has finished.""" + if prog == "schtasks": + action = next((w for w in lower[1:] if w in + ("/create", "/query", "/change", "/delete", "/run", "/end", "/showsid")), None) + if action == "/create": + return "creates a scheduled task that can run after this session (schtasks /create)" + if prog == "register-scheduledtask": + return "registers a task that can run after this session (Register-ScheduledTask)" + if (prog == "reg" and lower[1:2] == ["add"]) or prog in ("new-itemproperty", "set-itemproperty"): + if any(re.search(r"(?:^|\\)currentversion\\run(?:once)?$", w.rstrip("\\")) for w in lower[1:]): + return "adds a Windows Run key value that starts a program at login" + if prog == "systemctl": + args = iter(lower[1:]) + for word in args: + if word in ("-h", "--host", "-m", "--machine", "--root", "--image"): + next(args, None) + continue + if word.startswith("-"): + continue + if word == "enable": + return "enables a service to start automatically (systemctl enable)" + break + if prog == "launchctl" and lower[1:2] == ["load"]: + return "loads a launch agent or daemon that can run after this session (launchctl load)" + if prog == "crontab" and "-l" not in lower and "-r" not in lower: + if "-e" in lower or "-" in lower[1:] or _positional(lower): + return "installs or edits scheduled commands that will run later (crontab)" + return None + + + def classify(words: list[str], raw: str = "", depth: int = 0) -> tuple[list[Command], list[str]]: """One simple command (already split into words). -> (commands, hidden-reasons).""" words = words[1:] if words[:1] == ["{"] else words # a group, `{ curl URL > x.sh; } && bash x.sh` @@ -853,6 +885,9 @@ def classify(words: list[str], raw: str = "", depth: int = 0) -> tuple[list[Comm if prog == "crontab" and "-r" in lower: cmd.kind, cmd.why = "irreversible", "deletes every scheduled job (crontab -r)" return [cmd], [] + if (why := _starts_automatically(prog, lower)): + cmd.kind, cmd.why = "irreversible", why + return [cmd], [] if prog == "truncate" or prog in ("clear-content", "clc") or (prog == "mv" and any(w in ("/dev/null", "nul") for w in lower[1:])) \ or (prog == "cp" and lower[1:2] == ["/dev/null"] and len(_positional(words)) == 2): cmd.kind, cmd.why = "irreversible", f"empties or discards files ({' '.join(words[:3])})" diff --git a/tests/test_commands.py b/tests/test_commands.py index 3a87f2e..9995665 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -392,3 +392,49 @@ def test_gh_harmless_lookalikes_keep_existing_classification(line): ]) def test_gh_irreversible_reasons_describe_the_operation(line, reason): assert reason in commands.read(line).summary() + + +# These are inert command-reader inputs. No task, registry change or service is executed. +@pytest.mark.parametrize("line", [ + r"schtasks /create /sc onlogon /tn updater /tr C:\Users\me\x.exe", + r"SCHTASKS.EXE /Create /SC DAILY /TN example /TR C:\example.exe", + "Register-ScheduledTask -TaskName updater -Action $a -Trigger $t", + r"reg add HKCU\Software\Microsoft\Windows\CurrentVersion\Run /v updater /d x.exe", + r'reg add "HKLM\Software\Microsoft\Windows\CurrentVersion\RunOnce" /v test /d x.exe', + r"New-ItemProperty -Path HKCU:\Software\Microsoft\Windows\CurrentVersion\Run -Name updater -Value x.exe", + r'Set-ItemProperty -LiteralPath "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" -Name updater -Value x.exe', + "systemctl enable --now example.service", + "sudo systemctl --user enable example.service", + "systemctl --host example.invalid enable example.service", + "launchctl load -w ~/Library/LaunchAgents/com.example.plist", + "crontab -", + "crontab schedule.txt", + "crontab -e", + '(crontab -l; echo "@reboot echo example") | crontab -', +]) +def test_startup_changes_wait_for_a_person(line): + reading = commands.read(line) + assert reading.kind == "irreversible", reading.summary() + assert reading.summary() + + +@pytest.mark.parametrize("line", [ + "schtasks /query", + "schtasks /query /tn enable", + "systemctl status nginx", + "systemctl status enable", + "systemctl --user status nginx", + "launchctl list", + "launchctl print system/com.example", + "crontab -l", + "crontab -u root -l", + "Get-ScheduledTask", + r"reg query HKCU\Software\Microsoft\Windows\CurrentVersion\Run", + r"Get-ItemProperty -Path HKCU:\Software\Microsoft\Windows\CurrentVersion\Run", + r"reg add HKCU\Software\Example\Settings /v mode /d default", + r"New-ItemProperty -Path HKCU:\Software\Example\RunHistory -Name test -Value example", + "echo systemctl enable example.service", +]) +def test_startup_queries_and_lookalikes_are_not_persistence_changes(line): + reading = commands.read(line) + assert reading.kind in ("read_only", "other"), reading.summary()