Skip to content

Commit d9a7a81

Browse files
committed
Add --unblock option for side effects
1 parent 7182176 commit d9a7a81

File tree

10 files changed

+178
-29
lines changed

10 files changed

+178
-29
lines changed

crosshair/auditwall.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,17 @@
66
import traceback
77
from contextlib import contextmanager
88
from types import ModuleType
9-
from typing import Callable, Dict, Generator, Iterable, Optional, Sequence, Set, Tuple
9+
from typing import (
10+
Callable,
11+
Dict,
12+
Generator,
13+
Iterable,
14+
NoReturn,
15+
Optional,
16+
Sequence,
17+
Set,
18+
Tuple,
19+
)
1020

1121

1222
class SideEffectDetected(Exception):
@@ -24,13 +34,16 @@ def accept(event: str, args: Tuple) -> None:
2434

2535
def explain(event: str, args: Tuple) -> str:
2636
argstr = "".join(f":{arg}" for arg in args)
27-
return (
28-
f"CrossHair should not be run on code with side effects. "
29-
f'To allow this operation, use "--unblock={event}{argstr}" (or some colon-delimited prefix).'
30-
)
37+
parts = [
38+
f"It's dangerous to run CrossHair on code with side effects.",
39+
f'To allow this operation anyway, use "--unblock={event}{argstr}".',
40+
]
41+
if args:
42+
parts.append("(or some colon-delimited prefix)")
43+
return " ".join(parts)
3144

3245

33-
def reject(event: str, args: Tuple) -> None:
46+
def reject(event: str, args: Tuple) -> NoReturn:
3447
raise SideEffectDetected(
3548
f'A "{event}" operation was detected. ' + explain(event, args)
3649
)
@@ -88,7 +101,7 @@ def check_subprocess(event: str, args: Tuple) -> None:
88101
reject(event, args)
89102

90103

91-
_SPECIAL_HANDLERS = {
104+
_SPECIAL_HANDLERS: Dict[str, Callable[[str, Tuple], None]] = {
92105
"open": check_open,
93106
"subprocess.Popen": check_subprocess,
94107
"os.posix_spawn": check_subprocess,
@@ -189,6 +202,7 @@ def opened_auditwall() -> Generator:
189202

190203
def _make_prefix_based_handler(
191204
allowed_arg_prefixes: Sequence[Sequence[str]],
205+
previous_handler: Optional[Callable[[str, Tuple], None]] = None,
192206
) -> Callable[[str, Tuple], None]:
193207
trie: Dict = {}
194208
for prefix in allowed_arg_prefixes:
@@ -201,10 +215,14 @@ def handler(event: str, args: Tuple) -> None:
201215
current = trie
202216
for arg in map(str, args):
203217
if arg not in current:
204-
reject(event, args)
218+
break
205219
current = current[arg]
206220
if None in current:
207221
return # Found a valid prefix
222+
if previous_handler:
223+
previous_handler(event, args)
224+
else:
225+
reject(event, args)
208226

209227
return handler
210228

@@ -215,13 +233,17 @@ def _update_special_handlers(allow_prefixes: Sequence[str]) -> None:
215233
):
216234
group = tuple(group_itr)
217235
if any(event == g for g in group):
218-
_HANDLERS[event] = accept
236+
_SPECIAL_HANDLERS[event] = accept
219237
else:
220238
args = tuple(a.split(":")[1:] for a in group)
221-
_HANDLERS[event] = _make_prefix_based_handler(args)
239+
_SPECIAL_HANDLERS[event] = _make_prefix_based_handler(
240+
args, _SPECIAL_HANDLERS.get(event)
241+
)
222242

223243

224244
def engage_auditwall(allow_prefixes: Sequence[str] = ()) -> None:
245+
if "EVERYTHING" in allow_prefixes:
246+
return
225247
_update_special_handlers(allow_prefixes)
226248
sys.dont_write_bytecode = True # disable .pyc file writing
227249
sys.addaudithook(audithook)

crosshair/auditwall_test.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,40 @@ def test_fs_write_disallowed():
3030
assert call([pyexec, __file__, "write_open", "withwall"]) == 10
3131

3232

33+
def test_fs_write_allowed_if_prefix_allowed():
34+
try:
35+
assert (
36+
call(
37+
[
38+
pyexec,
39+
__file__,
40+
"write_open",
41+
"withwall",
42+
"open:./auditwall.testwrite.txt:w",
43+
]
44+
)
45+
== 0
46+
)
47+
# Confirm the new handler doesn't interfere with the prior handler:
48+
assert (
49+
call(
50+
[
51+
pyexec,
52+
__file__,
53+
"read_open",
54+
"withwall",
55+
"open:./auditwall.testwrite.txt:w",
56+
]
57+
)
58+
== 0
59+
)
60+
finally:
61+
try:
62+
os.unlink("./auditwall.testwrite.txt")
63+
except FileNotFoundError:
64+
pass
65+
66+
3367
def test_http_disallowed():
3468
assert call([pyexec, __file__, "http", "withwall"]) == 10
3569

@@ -104,7 +138,7 @@ def test_popen_via_platform_allowed():
104138
"read_open": lambda: open("/dev/null", "rb"),
105139
"scandir": lambda: os.scandir("."),
106140
"import": lambda: __import__("shutil"),
107-
"write_open": lambda: open("/.auditwall.testwrite.txt", "w"),
141+
"write_open": lambda: open("./auditwall.testwrite.txt", "w"),
108142
"http": lambda: urllib.request.urlopen("http://localhost/foo"),
109143
"unlink": lambda: os.unlink("./delme.txt"),
110144
"popen": lambda: call(["echo", "hello"]),

crosshair/main.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,25 @@ def command_line_parser() -> argparse.ArgumentParser:
102102
"--extra_plugin",
103103
type=str,
104104
nargs="+",
105+
metavar="FILE",
105106
help="Plugin file(s) you wish to use during the current execution",
106107
)
108+
common.add_argument(
109+
"--unblock",
110+
type=str,
111+
nargs="+",
112+
default=(),
113+
metavar="EVENT",
114+
help=textwrap.dedent(
115+
"""\
116+
Allow specific side-effects. See the list of audit events at:
117+
https://docs.python.org/3/library/audit_events.html
118+
You may specify colon-delimited event arguments to narrow the unblock, e.g.:
119+
--unblock subprocess.Popen:echo
120+
Finally, `--unblock EVERYTHING` will disable all side-effect detection.
121+
"""
122+
),
123+
)
107124
parser = argparse.ArgumentParser(
108125
prog="crosshair", description="CrossHair Analysis Tool"
109126
)
@@ -958,16 +975,18 @@ def mypy_and_check(cmd_args: Optional[List[str]] = None) -> None:
958975
if mypy_ret != 0:
959976
print(_mypy_out, file=sys.stdout)
960977
sys.exit(mypy_ret)
961-
engage_auditwall()
978+
engage_auditwall(check_args.unblock)
962979
debug("Running crosshair with these args:", check_args)
963980
sys.exit(unwalled_main(check_args))
964981

965982

966983
def main(cmd_args: Optional[List[str]] = None) -> None:
967984
if cmd_args is None:
968985
cmd_args = sys.argv[1:]
969-
engage_auditwall()
970-
sys.exit(unwalled_main(cmd_args))
986+
parsed_args = command_line_parser().parse_args(cmd_args)
987+
988+
engage_auditwall(parsed_args.unblock)
989+
sys.exit(unwalled_main(parsed_args))
971990

972991

973992
if __name__ == "__main__":

crosshair/options.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class AnalysisOptionSet:
5050
max_iterations: Optional[int] = None
5151
report_all: Optional[bool] = None
5252
report_verbose: Optional[bool] = None
53+
unblock: Optional[Sequence[str]] = None
5354
timeout: Optional[float] = None
5455
max_uninteresting_iterations: Optional[int] = None
5556

@@ -106,6 +107,7 @@ def option_set_from_dict(source: Mapping[str, object]) -> AnalysisOptionSet:
106107
"max_uninteresting_iterations",
107108
"report_all",
108109
"report_verbose",
110+
"unblock",
109111
):
110112
arg_val = source.get(optname, None)
111113
if arg_val is not None:
@@ -124,6 +126,7 @@ class AnalysisOptions:
124126
max_iterations: int
125127
report_all: bool
126128
report_verbose: bool
129+
unblock: Sequence[str]
127130
timeout: float
128131
per_path_timeout: float
129132
max_uninteresting_iterations: int
@@ -212,6 +215,7 @@ def incr(self, key: str):
212215
max_iterations=sys.maxsize,
213216
report_all=False,
214217
report_verbose=True,
218+
unblock=(),
215219
timeout=float("inf"),
216220
per_path_timeout=float("NaN"),
217221
max_uninteresting_iterations=sys.maxsize,

crosshair/watcher.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
run_checkables,
3434
)
3535
from crosshair.fnutil import NotFound, walk_paths
36-
from crosshair.options import AnalysisOptionSet
36+
from crosshair.options import DEFAULT_OPTIONS, AnalysisOptionSet
3737
from crosshair.util import (
3838
CrossHairInternal,
3939
ErrorDuringImport,
@@ -137,7 +137,7 @@ def pool_worker_main() -> None:
137137
# Windows, where the nice function does not exist:
138138
os.nice(10) # type: ignore
139139
set_debug(False)
140-
engage_auditwall()
140+
engage_auditwall(DEFAULT_OPTIONS.overlay(item[1]).unblock)
141141
(stats, messages) = pool_worker_process_item(item)
142142
output: WorkItemOutput = (filename, stats, messages)
143143
print(serialize(output))

crosshair/watcher_test.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import pytest
77

8+
from crosshair.main import command_line_parser
9+
from crosshair.options import option_set_from_dict
810
from crosshair.statespace import MessageType
911
from crosshair.test_util import simplefs
1012
from crosshair.watcher import Watcher
@@ -49,6 +51,16 @@ def foofn(x: int) -> int:
4951
"""
5052
}
5153

54+
CHATTY_FOO = {
55+
"foo.py": """
56+
from subprocess import Popen
57+
def foofn(x: int) -> int:
58+
''' post: _ == x '''
59+
Popen(['echo', 'hello']).communicate()
60+
return x
61+
"""
62+
}
63+
5264
EMPTY_BAR = {
5365
"bar.py": """
5466
# Nothing here
@@ -105,3 +117,27 @@ def test_removed_file_given_as_argument(tmp_path: Path):
105117
assert watcher.check_changed()
106118
(tmp_path / "foo.py").unlink()
107119
assert watcher.check_changed()
120+
121+
122+
@pytest.mark.parametrize(
123+
"unblock,expected_state",
124+
[
125+
("--unblock=subprocess.Popen:echo", MessageType.CONFIRMED),
126+
("--unblock=subprocess.Popen:xyz", MessageType.EXEC_ERR),
127+
],
128+
)
129+
def test_auditwall_unblock(unblock: str, expected_state: MessageType, tmp_path: Path):
130+
parsed_args, _ = command_line_parser().parse_known_args(
131+
["watch", unblock, "-v", "dummy"]
132+
)
133+
optionset = option_set_from_dict(parsed_args.__dict__)
134+
simplefs(tmp_path, CHATTY_FOO)
135+
watcher = Watcher([tmp_path], optionset)
136+
137+
watcher._next_file_check = time.time() - 1
138+
# Detect file (yields empty result to wake up the loop)
139+
assert list(watcher.run_iteration()) == [(Counter(), [])]
140+
# real results in new file after restart:
141+
results = list(watcher.run_iteration())
142+
assert len(results) == 1
143+
assert [m.state for m in results[0][1]] == [expected_state]

doc/source/changelog.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ Next Version
99
* Nothing yet!
1010

1111

12+
Version 0.0.101
13+
---------------
14+
15+
* Support user-defined exceptions for CrossHair's side-effect detection
16+
mechanism with a new ``--unblock`` command-line option.
17+
(resolves `#383 <https://github.com/pschanely/CrossHair/issues/383>`__)
18+
19+
1220
Version 0.0.100
1321
---------------
1422

doc/source/contracts.rst

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,8 @@ Type Ctrl-C to stop this command.
9797
.. Help starts: crosshair watch --help
9898
.. code-block:: text
9999
100-
usage: crosshair watch [-h] [--verbose]
101-
[--extra_plugin EXTRA_PLUGIN [EXTRA_PLUGIN ...]]
102-
[--analysis_kind KIND]
100+
usage: crosshair watch [-h] [--verbose] [--extra_plugin FILE [FILE ...]]
101+
[--unblock EVENT [EVENT ...]] [--analysis_kind KIND]
103102
TARGET [TARGET ...]
104103
105104
The watch command continuously looks for contract counterexamples.
@@ -112,8 +111,14 @@ Type Ctrl-C to stop this command.
112111
options:
113112
-h, --help show this help message and exit
114113
--verbose, -v Output additional debugging information on stderr
115-
--extra_plugin EXTRA_PLUGIN [EXTRA_PLUGIN ...]
114+
--extra_plugin FILE [FILE ...]
116115
Plugin file(s) you wish to use during the current execution
116+
--unblock EVENT [EVENT ...]
117+
Allow specific side-effects. See the list of audit events at:
118+
https://docs.python.org/3/library/audit_events.html
119+
You may specify colon-delimited event arguments to narrow the unblock, e.g.:
120+
--unblock subprocess.Popen:echo
121+
Finally, `--unblock EVERYTHING` will disable all side-effect detection.
117122
--analysis_kind KIND Kind of contract to check.
118123
By default, the PEP316, deal, and icontract kinds are all checked.
119124
Multiple kinds (comma-separated) may be given.
@@ -137,9 +142,9 @@ It is more customizable than ``watch`` and produces machine-readable output.
137142
.. Help starts: crosshair check --help
138143
.. code-block:: text
139144
140-
usage: crosshair check [-h] [--verbose]
141-
[--extra_plugin EXTRA_PLUGIN [EXTRA_PLUGIN ...]]
142-
[--report_all] [--report_verbose]
145+
usage: crosshair check [-h] [--verbose] [--extra_plugin FILE [FILE ...]]
146+
[--unblock EVENT [EVENT ...]] [--report_all]
147+
[--report_verbose]
143148
[--max_uninteresting_iterations MAX_UNINTERESTING_ITERATIONS]
144149
[--per_path_timeout FLOAT]
145150
[--per_condition_timeout FLOAT] [--analysis_kind KIND]
@@ -164,8 +169,14 @@ It is more customizable than ``watch`` and produces machine-readable output.
164169
options:
165170
-h, --help show this help message and exit
166171
--verbose, -v Output additional debugging information on stderr
167-
--extra_plugin EXTRA_PLUGIN [EXTRA_PLUGIN ...]
172+
--extra_plugin FILE [FILE ...]
168173
Plugin file(s) you wish to use during the current execution
174+
--unblock EVENT [EVENT ...]
175+
Allow specific side-effects. See the list of audit events at:
176+
https://docs.python.org/3/library/audit_events.html
177+
You may specify colon-delimited event arguments to narrow the unblock, e.g.:
178+
--unblock subprocess.Popen:echo
179+
Finally, `--unblock EVERYTHING` will disable all side-effect detection.
169180
--report_all Output analysis results for all postconditions (not just failing ones)
170181
--report_verbose Output context and stack traces for counterexamples
171182
--max_uninteresting_iterations MAX_UNINTERESTING_ITERATIONS
@@ -287,3 +298,5 @@ directly or indirectly cause side-effects.
287298
CrossHair puts some protections in place (via ``sys.addaudithook``) to prevent disk
288299
and network access, but this protection is not perfect. (notably, it will not
289300
prevent actions taken by C-based modules)
301+
You can bypass CrossHair's internal protections with the ``--unblock`` command-line
302+
option, which may be appropriate in a containerized or otherwise isolated environment.

0 commit comments

Comments
 (0)