r"\b(?:(?:os|process|proc|subprocess).)?(?:killpg|kill)"
Root cause. The blocklist rule builtin-killpg-pid1 matched only one target digit: 1. But POSIX treats 0 as a mass-kill target too — kill(0, sig) / killpg(0, sig) signal every process in the calling process's own process group. So killpg(0), os.killpg(0), kill(0), os.kill(0), and process.kill(0) all produce the same MagicMock incident class as killpg(1) but sailed straight past the [1]-only regex.
The fix. Extend the target class [1] → [01], with digit-boundary guards so killpg(10, …) / kill(4242, …) are not false-positives (the 0 inside 10 must not match). Negative ids (-1 = broadcast to all permitted processes, -N = process group N) are covered as part of the same adversarial enumeration.
import re
# vulnerable rule: only pid 1 matched
OLD_KILL_RE = re.compile(
r"\b(?:(?:os|process|proc|subprocess)\.)?(?:killpg|kill)"
r"\s*\(\s*1\s*(?:,|\))", re.IGNORECASE)
# fixed rule: targets {0 = own pgroup, 1 = pid/pgroup 1} + broadcast -N
_FIXED_TARGET = r"(?<!\d)(?:0|1|-\s*\d+)(?!\d)"
KILL_RE = re.compile(
r"\b(?:(?:os|process|proc|subprocess)\.)?(?:killpg|kill)"
r"\s*\(\s*" + _FIXED_TARGET + r"\s*(?:,|\))", re.IGNORECASE)
def blocked_by(source: str, rule: re.Pattern = KILL_RE) -> bool:
return rule.search(source) is not None
Key details:
- (?<!\d) / (?!\d) prevent killpg(10, …) from matching via its trailing 0, and kill(4242, …) via its leading 4…1 digit — only standalone 0/1 are special targets.
- (?:,|\)) covers both kill(0, sig) and the no-signal spellings kill(0) / process.kill(0).
- No (?<![\w.]) guard before kill: myos.kill(0, 9) is intentionally blocked — any receiver object's .kill(0) is an own-pgroup mass kill, so the bare kill(0 substring matches regardless of prefix. Tightening that boundary would recreate the exact bypass class.
- The scanner is static (never executes the call), so killpg(0) can never actually signal the sandbox's process group.
12 regression cases (all in the committed suite): 5 call families (killpg, os.killpg, kill, os.kill, process.kill) × 2 special targets (1, 0) = 10 blocked, plus 2 allow cases (killpg(10, …), os.kill(4242, …)) proving no over-match.
**9-vector adversarial probe** — old rule vs new rule on every spelling in the incident class: ``` vector old rule new rule killpg(1, 9) BLOCKED BLOCKED os.killpg(1, 9) BLOCKED BLOCKED killpg(0, 9) ALLOWED BLOCKED <-- GAP (was allowed) os.killpg(0, 9) ALLOWED BLOCKED <-- GAP (was allowed) kill(1, 9) BLOCKED BLOCKED os.kill(1, 9) BLOCKED BLOCKED kill(0, 9) ALLOWED BLOCKED <-- GAP (was allowed) os.kill(0, 9) ALLOWED BLOCKED <-- GAP (was allowed) process.kill(0) ALLOWED BLOCKED <-- GAP (was allowed) ``` **12 regression cases: 12/12 PASS (exit 0).** Each gap case additionally re-verified to be ALLOWED by the old rule, confirming the test would have caught the regression. `os.kill`/`os.killpg`/`process.kill` were stubbed with MagicMock recorders in the runtime demo — every vector was intercepted as `INCIDENT (blocked by rule)` before any syscall could fire (no real signal ever sent). **Edge cases tested (24 total):** - Whitespace/newlines inside calls: `killpg( 0 , 9 )`, `os.killpg(\n 0 ,\n 9\n)` → BLOCK - Broadcast/negative: `kill(-1, 9)`, `os.kill(-1, 9)`, `kill(-42, 9)` → BLOCK - No-signal spellings: `kill(0)`, `process.kill(0)` → BLOCK - Digit-boundary (no false positives): `killpg(10, 9)`, `os.kill(100, 9)` → ALLOW - Any-receiver conservatism: `myos.kill(0, 9)`, `spawner.killpg(0, 9)` → BLOCK (documented as correct) - Known regex limitation: `killpg(0x0, 9)` (hex) is not matched — noted; an AST-based detector is the follow-up hardening. **Method note:** the gap was only found by probe-driven enumeration (9-vector killpg probe), not by reading the spec examples — pattern-based blocklist rules must enumerate adversarial vectors (`0`, `-1`, `-N`, whitespace, no-signal forms, arbitrary receivers) beyond the documented `killpg(1)` case. ---
{"model": "deepseek-v4-flash", "problem_class": "python-security-pattern-gap-own-pgroup-kill", "result": "passed", "tests": 12}