Skip to content

Commit 71dc42d

Browse files
committed
✨ Add full scan & leaked api key detection
1 parent ec8b3eb commit 71dc42d

File tree

4 files changed

+546
-68
lines changed

4 files changed

+546
-68
lines changed

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,25 @@
22

33
The File Check plugin tries to detect common issues in uploaded files that are known to cause
44
issues while printing and which in the past have caused support requests on OctoPrint's Community
5-
Forums.
5+
Forums, as well as leaked sensitive data such as API keys.
66

77
![Screenshot](https://raw.githubusercontent.com/OctoPrint/OctoPrint-FileCheck/master/extras/screenshot.png)
88

99
It currently detects and warns about the following issues:
1010

1111
* Outdated placeholder `{travel_speed}` left in the GCODE generated by the slicer. See
1212
[here](https://faq.octoprint.org/file-check-travel-speed) for details on this.
13+
* API keys leaked by the slicer into the GCODE. See
14+
[here](https://faq.octoprint.org/file-check-leaked-api-key) for details on this.
15+
16+
Since version @@TODO@@ it also supports checking selected files as well as all uploaded files for issues on
17+
the press of a button (if `grep` is available), not just freshly uploaded files.
1318

1419
## Setup
1520

1621
The plugin is part of the core dependencies of OctoPrint 1.4.1+ and will be installed automatically alongside it.
1722

18-
In case you want to manually install it into an older version for whatever reason, install via the bundled
23+
In case you want to manually install it for whatever reason, install via the bundled
1924
[Plugin Manager](https://docs.octoprint.org/en/master/bundledplugins/pluginmanager.html)
2025
or manually using this URL:
2126

octoprint_file_check/__init__.py

Lines changed: 224 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,66 @@
11
__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html"
22
__copyright__ = "Copyright (C) 2020 The OctoPrint Project - Released under terms of the AGPLv3 License"
33

4+
import os
45
import re
6+
import threading
7+
from collections import defaultdict
58

69
# noinspection PyCompatibility
710
from concurrent.futures import ThreadPoolExecutor
811

12+
import flask
13+
import octoprint.access.permissions
914
import octoprint.events
1015
import octoprint.plugin
1116
import sarge
1217
from flask_babel import gettext
18+
from octoprint.filemanager import get_file_type
19+
20+
CHECKS = {
21+
"travel_speed": {
22+
"pattern": "{travel_speed}",
23+
},
24+
"leaked_api_key": {
25+
"pattern": r";\s+printhost_apikey\s+=\s+\S+",
26+
"regex": True,
27+
},
28+
}
1329

1430

15-
class FileCheckPlugin(octoprint.plugin.AssetPlugin, octoprint.plugin.EventHandlerPlugin):
31+
class FileCheckPlugin(
32+
octoprint.plugin.AssetPlugin,
33+
octoprint.plugin.EventHandlerPlugin,
34+
octoprint.plugin.SettingsPlugin,
35+
octoprint.plugin.SimpleApiPlugin,
36+
):
1637
def __init__(self):
1738
self._executor = ThreadPoolExecutor()
1839

40+
self._native_grep_available = True
41+
42+
self._full_check_lock = threading.RLock()
43+
self._check_result = {}
44+
45+
def initialize(self):
46+
try:
47+
sarge.run(["grep", "-q", "--version"])
48+
except Exception as exc:
49+
if "Command not found" in str(exc):
50+
self._native_grep_available = False
51+
self._logger.info(f"Native grep available: {self._native_grep_available}")
52+
1953
##~~ AssetPlugin API
2054

2155
def get_assets(self):
22-
return dict(js=("js/file_check.js",))
56+
return {
57+
"js": [
58+
"js/file_check.js",
59+
],
60+
"clientjs": [
61+
"clientjs/file_check.js",
62+
],
63+
}
2364

2465
##~~ EventHandlerPlugin API
2566

@@ -29,6 +70,66 @@ def on_event(self, event, payload):
2970
self._validate_file, payload["storage"], payload["path"], payload["type"]
3071
)
3172

73+
elif event == octoprint.events.Events.FILE_SELECTED:
74+
file_type = get_file_type(payload["name"])
75+
self._executor.submit(
76+
self._validate_file, payload["origin"], payload["path"], file_type
77+
)
78+
79+
elif event == octoprint.events.Events.FILE_REMOVED:
80+
dirty = True
81+
with self._full_check_lock:
82+
for check in self._check_result:
83+
current = len(self._check_result[check])
84+
self._check_result[check] = [
85+
path
86+
for path in self._check_result[check]
87+
if path != f"{payload['storage']}:{payload['path']}"
88+
]
89+
dirty = dirty or len(self._check_result[check]) < current
90+
if dirty:
91+
self._trigger_check_update()
92+
93+
elif event == octoprint.events.Events.FOLDER_REMOVED:
94+
dirty = False
95+
with self._full_check_lock:
96+
for check in self._check_result:
97+
current = len(self._check_result[check])
98+
self._check_result[check] = [
99+
path
100+
for path in self._check_result[check]
101+
if not path.startswith(f"{payload['storage']}:{payload['path']}/")
102+
]
103+
dirty = dirty or len(self._check_result[check]) < current
104+
if dirty:
105+
self._trigger_check_update()
106+
107+
##~~ SimpleApiPlugin API
108+
109+
def on_api_get(self, request):
110+
if not octoprint.access.permissions.Permissions.FILES_DOWNLOAD.can():
111+
return flask.make_response("Insufficient rights", 403)
112+
113+
response = {
114+
"native_grep": self._native_grep_available,
115+
"check_result": self._check_result,
116+
}
117+
return flask.jsonify(**response)
118+
119+
def get_api_commands(self):
120+
return {"check_all": []}
121+
122+
def on_api_command(self, command, data):
123+
if command == "check_all":
124+
if not octoprint.access.permissions.Permissions.FILES_DOWNLOAD.can():
125+
return flask.make_response("Insufficient rights", 403)
126+
127+
self._start_full_check()
128+
return flask.Response(
129+
status=202,
130+
headers={"Location": flask.url_for("index") + "api/plugin/file_check"},
131+
)
132+
32133
##~~ SoftwareUpdate hook
33134

34135
def get_update_information(self):
@@ -60,48 +161,112 @@ def get_update_information(self):
60161

61162
##~~ Internal logic & helpers
62163

164+
def _start_full_check(self):
165+
with self._full_check_lock:
166+
self._check_result = None
167+
job = self._executor.submit(self._check_all_files)
168+
job.add_done_callback(self._full_check_done)
169+
170+
def _full_check_done(self, future):
171+
try:
172+
result = future.result()
173+
except Exception:
174+
self._logger.exception("Full check failed")
175+
return
176+
177+
path_to_checks = defaultdict(list)
178+
for check, matches in result.items():
179+
for match in matches:
180+
path_to_checks[match].append(check)
181+
182+
self._trigger_check_update()
183+
184+
def _check_all_files(self):
185+
with self._full_check_lock:
186+
if not self._native_grep_available:
187+
return {}
188+
189+
path = self._settings.global_get_basefolder("uploads")
190+
self._logger.info(f"Running check on all files in {path} (local storage)")
191+
192+
full_check_result = {}
193+
for check, params in CHECKS.items():
194+
self._logger.info(f"Running check {check}")
195+
pattern = params["pattern"]
196+
sanitized = self._sanitize_pattern(
197+
pattern,
198+
incl_comments=params.get("incl_comments", False),
199+
regex=params.get("regex", False),
200+
)
201+
202+
result = sarge.capture_both(["grep", "-r", "-E", sanitized, path])
203+
if result.stderr.text:
204+
self._logger.warning(
205+
f"Error raised by native grep, can't run check {check} on all files"
206+
)
207+
continue
208+
209+
matches = []
210+
if result.returncode == 0:
211+
for line in result.stdout.text.splitlines():
212+
p, _ = line.split(":", 1)
213+
matches.append("local:" + p.replace(path + os.path.sep, ""))
214+
215+
self._logger.info(f"... got {len(matches)} matches")
216+
full_check_result[check] = matches
217+
218+
self._check_result = full_check_result
219+
return full_check_result
220+
63221
def _validate_file(self, storage, path, file_type):
64222
try:
65223
path_on_disk = self._file_manager.path_on_disk(storage, path)
66224
except NotImplementedError:
67225
# storage doesn't support path_on_disk, ignore
68226
return
69227

70-
if file_type[-1] == "gcode":
71-
if self._search_through_file(path_on_disk, "{travel_speed}"):
72-
self._notify("travel_speed", storage, path)
228+
if file_type[-1] != "gcode":
229+
return
73230

74-
def _search_through_file(self, path, term, incl_comments=False):
75-
if incl_comments:
76-
pattern = re.escape(term)
77-
else:
78-
pattern = r"^[^;]*" + re.escape(term)
231+
types = []
232+
for check, params in CHECKS.items():
233+
pattern = params["pattern"]
234+
if self._search_through_file(
235+
path_on_disk,
236+
pattern,
237+
incl_comments=params.get("incl_comments", False),
238+
regex=params.get("regex", False),
239+
):
240+
types.append(check)
241+
242+
if types:
243+
self._notify(storage, path, types)
244+
245+
def _search_through_file(self, path, pattern, incl_comments=False, regex=False):
246+
sanitized = self._sanitize_pattern(
247+
pattern, incl_comments=incl_comments, regex=regex
248+
)
79249
compiled = re.compile(pattern)
80250

81251
try:
82-
try:
83-
# try native grep
84-
result = sarge.capture_stderr(["grep", "-q", "-E", pattern, path])
85-
if result.stderr.text:
86-
self._logger.warning(
87-
"Error raised by native grep, falling back to python "
88-
"implementation: {}".format(result.stderr.text.strip())
89-
)
90-
return self._search_through_file_python(
91-
path, term, compiled, incl_comments=incl_comments
92-
)
93-
return result.returncode == 0
94-
except ValueError as exc:
95-
if "Command not found" in str(exc):
96-
return self._search_through_file_python(
97-
path, term, compiled, incl_comments=incl_comments
98-
)
99-
else:
100-
raise
252+
if self._native_grep_available:
253+
result = sarge.capture_stderr(["grep", "-q", "-E", sanitized, path])
254+
if not result.stderr.text:
255+
return result.returncode == 0
256+
257+
self._logger.warning(
258+
"Error raised by native grep, falling back to python "
259+
"implementation: {}".format(result.stderr.text.strip())
260+
)
261+
262+
return self._search_through_file_python(
263+
path, sanitized, compiled, incl_comments=incl_comments
264+
)
265+
101266
except Exception:
102267
self._logger.exception(
103268
"Something unexpectedly went wrong while trying to "
104-
"search for {} in {} via grep".format(term, path)
269+
"search for {} in {}".format(pattern, path)
105270
)
106271

107272
return False
@@ -113,20 +278,42 @@ def _search_through_file_python(self, path, term, compiled, incl_comments=False)
113278
return True
114279
return False
115280

116-
def _notify(self, notification_type, storage, path):
117-
self._logger.warning(
118-
"File check identified an issue: {} for {}:{}, see "
119-
"https://faq.octoprint.org/file-check-{} for details".format(
120-
notification_type, storage, path, notification_type.replace("_", "-")
281+
def _sanitize_pattern(self, pattern, incl_comments=False, regex=False):
282+
if regex:
283+
return pattern
284+
285+
if incl_comments:
286+
return re.escape(pattern)
287+
else:
288+
return r"^[^;]*" + re.escape(pattern)
289+
290+
def _notify(self, storage, path, types):
291+
self._logger.warning(f"File check identified issues for {storage}:{path}:")
292+
for t in types:
293+
self._logger.warning(
294+
f" {t}, see https://faq.octoprint.org/file-check-{t.replace('_', '-')} for details"
121295
)
296+
297+
with self._full_check_lock:
298+
for t in types:
299+
if t not in self._check_result:
300+
self._check_result[t] = []
301+
if path not in self._check_result[t]:
302+
self._check_result[t].append(f"{storage}:{path}")
303+
304+
self._plugin_manager.send_plugin_message(
305+
self._identifier,
306+
{"action": "notify", "storage": storage, "path": path, "types": types},
122307
)
308+
309+
def _trigger_check_update(self):
123310
self._plugin_manager.send_plugin_message(
124-
self._identifier, dict(type=notification_type, storage=storage, path=path)
311+
self._identifier, dict(action="check_update")
125312
)
126313

127314

128315
__plugin_name__ = "File Check"
129-
__plugin_pythoncompat__ = ">2.7,<4"
316+
__plugin_pythoncompat__ = ">3.7,<4"
130317
__plugin_disabling_discouraged__ = gettext(
131318
"Without this plugin OctoPrint will no longer be able to "
132319
"check if uploaded files contain common problems and inform you "
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
(function (global, factory) {
2+
if (typeof define === "function" && define.amd) {
3+
define(["OctoPrintClient"], factory);
4+
} else {
5+
factory(global.OctoPrintClient);
6+
}
7+
})(this, function (OctoPrintClient) {
8+
var OctoPrintFileCheckClient = function (base) {
9+
this.base = base;
10+
};
11+
12+
OctoPrintFileCheckClient.prototype.get = function (opts) {
13+
return this.base.simpleApiGet("file_check", opts);
14+
};
15+
16+
OctoPrintFileCheckClient.prototype.checkAll = function (opts) {
17+
return this.base.simpleApiCommand("file_check", "check_all", opts);
18+
};
19+
20+
OctoPrintClient.registerPluginComponent("file_check", OctoPrintFileCheckClient);
21+
return OctoPrintFileCheckClient;
22+
});

0 commit comments

Comments
 (0)