devshield-scanner 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devshield_linux.py +166 -0
- devshield_scanner-0.1.0.dist-info/METADATA +57 -0
- devshield_scanner-0.1.0.dist-info/RECORD +7 -0
- devshield_scanner-0.1.0.dist-info/WHEEL +5 -0
- devshield_scanner-0.1.0.dist-info/entry_points.txt +2 -0
- devshield_scanner-0.1.0.dist-info/licenses/LICENSE +21 -0
- devshield_scanner-0.1.0.dist-info/top_level.txt +1 -0
devshield_linux.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only Linux host/Docker filesystem scan using the installed ClamAV engine."""
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main():
|
|
18
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
19
|
+
parser.add_argument('--version', action='version', version='DevShield 0.1.0')
|
|
20
|
+
parser.add_argument('--host-root', default='/', help='Host directory to scan (default /)')
|
|
21
|
+
parser.add_argument('--skip-host', action='store_true')
|
|
22
|
+
parser.add_argument('--skip-docker', action='store_true')
|
|
23
|
+
parser.add_argument('--reports', default='/var/log/devshield')
|
|
24
|
+
parser.add_argument('--temp-dir', help='Scratch filesystem with enough space for container exports')
|
|
25
|
+
parser.add_argument('--heartbeat', type=int, default=10)
|
|
26
|
+
args = parser.parse_args()
|
|
27
|
+
if not sys.platform.startswith('linux'):
|
|
28
|
+
parser.error('Run this script on Linux.')
|
|
29
|
+
if args.heartbeat < 1:
|
|
30
|
+
parser.error('--heartbeat must be positive')
|
|
31
|
+
if not shutil.which('clamscan'):
|
|
32
|
+
parser.error('clamscan is missing. Install ClamAV and update its databases first; see README.md.')
|
|
33
|
+
report_dir = Path(args.reports).resolve() / (dt.datetime.now(dt.timezone.utc).strftime('%Y%m%dT%H%M%SZ') + '-' + str(os.getpid()))
|
|
34
|
+
report_dir.mkdir(parents=True, mode=0o700)
|
|
35
|
+
report = {'started': dt.datetime.now(dt.timezone.utc).isoformat(), 'scans': [], 'issues': [],
|
|
36
|
+
'limitations': ['Filesystem signature scan, not proof of absence of malware or rootkits.',
|
|
37
|
+
'Files can change during live scans; no atomic snapshot is taken.',
|
|
38
|
+
'Stopped containers and unused image layers are not exported.',
|
|
39
|
+
'Container exports omit volume/bind contents; local mount sources are scanned separately.',
|
|
40
|
+
'ClamAV archive, file size and recursion limits apply; encrypted/unreadable content may be missed.',
|
|
41
|
+
'Virtual filesystems and raw Docker storage are excluded from the host scan.']}
|
|
42
|
+
def save():
|
|
43
|
+
tmp = report_dir / 'report.json.tmp'
|
|
44
|
+
tmp.write_text(json.dumps(report, indent=2) + '\n')
|
|
45
|
+
tmp.replace(report_dir / 'report.json')
|
|
46
|
+
def issue(message):
|
|
47
|
+
print('COVERAGE ISSUE: ' + message, flush=True)
|
|
48
|
+
report['issues'].append(message)
|
|
49
|
+
save()
|
|
50
|
+
def run(command, label, output=None):
|
|
51
|
+
"""Save output directly to disk, and keep console alive without buffering logs in RAM."""
|
|
52
|
+
index = len(list(report_dir.glob('*.log')))
|
|
53
|
+
log = report_dir / f'{index:04d}.log'
|
|
54
|
+
print(f'START {label} | log: {log}', flush=True)
|
|
55
|
+
start = time.monotonic()
|
|
56
|
+
with log.open('wb') as stderr:
|
|
57
|
+
with (open(output, 'wb') if output else contextlib.nullcontext(stderr)) as stdout:
|
|
58
|
+
proc = subprocess.Popen(command, stdout=stdout, stderr=stderr)
|
|
59
|
+
try:
|
|
60
|
+
while True:
|
|
61
|
+
try:
|
|
62
|
+
rc = proc.wait(timeout=args.heartbeat)
|
|
63
|
+
break
|
|
64
|
+
except subprocess.TimeoutExpired:
|
|
65
|
+
print(f'WORKING {label} | elapsed {int(time.monotonic()-start)}s | log bytes {log.stat().st_size}', flush=True)
|
|
66
|
+
except BaseException:
|
|
67
|
+
proc.terminate()
|
|
68
|
+
try:
|
|
69
|
+
proc.wait(timeout=10)
|
|
70
|
+
except subprocess.TimeoutExpired:
|
|
71
|
+
proc.kill()
|
|
72
|
+
proc.wait()
|
|
73
|
+
raise
|
|
74
|
+
print(f'FINISHED {label} | exit {rc}', flush=True)
|
|
75
|
+
return rc, str(log)
|
|
76
|
+
def docker_json(command):
|
|
77
|
+
result = subprocess.run(['docker', *command], capture_output=True, text=True)
|
|
78
|
+
if result.returncode:
|
|
79
|
+
raise RuntimeError(result.stderr.strip() or 'Docker command failed')
|
|
80
|
+
return json.loads(result.stdout)
|
|
81
|
+
scratch = tempfile.TemporaryDirectory(prefix='devshield-', dir=args.temp_dir)
|
|
82
|
+
def scan(path, label, excludes=()):
|
|
83
|
+
command = ['clamscan', '--recursive=yes', '--infected', '--verbose', '--scan-archive=yes',
|
|
84
|
+
'--cross-fs=yes', '--follow-dir-symlinks=0', '--follow-file-symlinks=0']
|
|
85
|
+
command.extend('--exclude-dir=^' + re.escape(str(Path(p).resolve())) + r'(/|$)' for p in excludes)
|
|
86
|
+
rc, log = run([*command, '--', str(path)], label)
|
|
87
|
+
report['scans'].append({'target': str(path), 'label': label, 'exit_code': rc, 'log': log,
|
|
88
|
+
'result': {0: 'No signature detections reported', 1: 'Detection reported'}.get(rc, 'Scan error')})
|
|
89
|
+
save()
|
|
90
|
+
if rc not in (0, 1):
|
|
91
|
+
issue(f'{label}: ClamAV failed; see {log}')
|
|
92
|
+
save()
|
|
93
|
+
try:
|
|
94
|
+
if os.geteuid() != 0:
|
|
95
|
+
issue('Not running as root; protected host files and Docker access may be unavailable.')
|
|
96
|
+
version = subprocess.run(['clamscan', '--version'], capture_output=True, text=True)
|
|
97
|
+
report['engine'] = version.stdout.strip()
|
|
98
|
+
save()
|
|
99
|
+
if not args.skip_host:
|
|
100
|
+
if not Path(args.host_root).is_dir():
|
|
101
|
+
issue('Host root is not a directory: ' + args.host_root)
|
|
102
|
+
else:
|
|
103
|
+
scan(Path(args.host_root).resolve(), 'Host filesystem',
|
|
104
|
+
['/proc', '/sys', '/dev', '/run', '/var/lib/docker', '/var/lib/containerd', report_dir.parent, scratch.name])
|
|
105
|
+
if not args.skip_docker:
|
|
106
|
+
if not shutil.which('docker'):
|
|
107
|
+
issue('Docker CLI missing; containers and volumes were not inspected.')
|
|
108
|
+
else:
|
|
109
|
+
try:
|
|
110
|
+
info = docker_json(['info', '--format', '{{json .}}'])
|
|
111
|
+
if info.get('OSType') != 'linux':
|
|
112
|
+
raise RuntimeError('Docker daemon is not Linux.')
|
|
113
|
+
context = docker_json(['context', 'inspect'])
|
|
114
|
+
endpoint = context[0]['Endpoints']['docker']['Host']
|
|
115
|
+
if os.environ.get('DOCKER_HOST') or not endpoint.startswith('unix://'):
|
|
116
|
+
raise RuntimeError('Use a local Unix socket Docker context and unset DOCKER_HOST; remote mount paths cannot be safely scanned locally.')
|
|
117
|
+
ids = subprocess.run(['docker', 'ps', '-q', '--no-trunc'], capture_output=True, text=True, check=True).stdout.split()
|
|
118
|
+
report['running_containers'] = ids
|
|
119
|
+
mounts = set()
|
|
120
|
+
for cid in ids:
|
|
121
|
+
try:
|
|
122
|
+
container = docker_json(['inspect', cid])[0]
|
|
123
|
+
mounts.update(m['Source'] for m in container.get('Mounts', []) if m.get('Source'))
|
|
124
|
+
archive = Path(scratch.name) / (cid + '.tar')
|
|
125
|
+
rc, log = run(['docker', 'export', cid], 'Export container ' + cid, archive)
|
|
126
|
+
if rc:
|
|
127
|
+
issue(f'Container {cid} export failed; see {log}')
|
|
128
|
+
else:
|
|
129
|
+
scan(archive, 'Container filesystem ' + cid)
|
|
130
|
+
archive.unlink(missing_ok=True)
|
|
131
|
+
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as exc:
|
|
132
|
+
issue(f'Container {cid}: {exc}')
|
|
133
|
+
# Include unused local volumes as well as mounts from running containers.
|
|
134
|
+
volume_names = subprocess.run(['docker', 'volume', 'ls', '-q'], capture_output=True, text=True, check=True).stdout.split()
|
|
135
|
+
for name in volume_names:
|
|
136
|
+
try:
|
|
137
|
+
volume = docker_json(['volume', 'inspect', name])[0]
|
|
138
|
+
if volume.get('Driver') != 'local' or volume.get('Options'):
|
|
139
|
+
issue(f'Volume {name}: external/plugin/configured volume may require separate scanning.')
|
|
140
|
+
if volume.get('Mountpoint'):
|
|
141
|
+
mounts.add(volume['Mountpoint'])
|
|
142
|
+
except (RuntimeError, ValueError) as exc:
|
|
143
|
+
issue(f'Volume {name}: {exc}')
|
|
144
|
+
for source in sorted(mounts):
|
|
145
|
+
if Path(source).exists():
|
|
146
|
+
scan(source, 'Docker mount ' + source, ['/proc', '/sys', '/dev', '/run', report_dir.parent, scratch.name])
|
|
147
|
+
else:
|
|
148
|
+
issue('Docker mount source inaccessible: ' + source)
|
|
149
|
+
except (OSError, RuntimeError, ValueError, KeyError, subprocess.SubprocessError) as exc:
|
|
150
|
+
issue('Docker discovery failed: ' + str(exc))
|
|
151
|
+
except KeyboardInterrupt:
|
|
152
|
+
issue('Interrupted; scan is incomplete.')
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
issue('Scan aborted: ' + str(exc))
|
|
155
|
+
finally:
|
|
156
|
+
scratch.cleanup()
|
|
157
|
+
report['finished'] = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
158
|
+
save()
|
|
159
|
+
print(f'Report: {report_dir / "report.json"}', flush=True)
|
|
160
|
+
if report['issues']:
|
|
161
|
+
return 2
|
|
162
|
+
return 1 if any(s['exit_code'] == 1 for s in report['scans']) else 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == '__main__':
|
|
166
|
+
sys.exit(main())
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devshield-scanner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read-only Linux host and Docker filesystem inspection using ClamAV
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# DevShield for Linux
|
|
16
|
+
|
|
17
|
+
An installable Python CLI package is included. See **INSTALL.md** to build a wheel and install the `devshield` command on servers using an isolated environment under `/opt/devshield`. There is no published npm or Python registry package yet. `devshield --version` displays the CLI version; existing scanner flags are unchanged.
|
|
18
|
+
|
|
19
|
+
Read-only host and Docker filesystem malware scanning using the locally installed ClamAV signature engine. Requires Linux, Python 3.8+, `clamscan`, updated ClamAV databases, and the Docker CLI for container scanning. Run on the Docker host with a local Unix socket context. Root access normally provides the required file/socket access.
|
|
20
|
+
|
|
21
|
+
On Debian/Ubuntu, install prerequisites using your normal package-management policy:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
sudo apt-get update
|
|
25
|
+
sudo apt-get install python3 clamav clamav-freshclam
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Ensure the ClamAV databases have downloaded before scanning. Use your distribution's freshclam service, or `sudo freshclam` if that service is not already managing updates. The scanner does not install software, change services, or update databases automatically. Docker must already be installed if you use it.
|
|
29
|
+
|
|
30
|
+
Copy this folder onto the server, then run:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
sudo python3 devshield_linux.py
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Console messages identify each stage and show a heartbeat every 10 seconds during long scans. Verbose file-level progress is saved to each stage's log; inspect a log with `tail -f /path/printed/by/script.log`. JSON results are checkpointed after every completed scan and on interruption, under `/var/log/devshield/<run>/report.json`.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
# Check a small directory first
|
|
40
|
+
sudo python3 devshield_linux.py --host-root /srv/project --skip-docker
|
|
41
|
+
|
|
42
|
+
# Containers and volumes only
|
|
43
|
+
sudo python3 devshield_linux.py --skip-host
|
|
44
|
+
|
|
45
|
+
# Use a scratch disk with room for the largest container filesystem export
|
|
46
|
+
sudo python3 devshield_linux.py --temp-dir /mnt/scratch --heartbeat 5
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Host scanning starts at `/` and traverses mounted filesystems, including potentially slow network mounts. `/proc`, `/sys`, `/dev`, `/run`, raw default Docker/containerd storage, the report directory and scratch directory are excluded. No symlinks are followed. Custom Docker storage may be scanned twice. Logs can be large and contain private file paths; keep them private.
|
|
50
|
+
|
|
51
|
+
For each running container the script uses `docker export`, scans the resulting TAR as an archive, then deletes only its own temporary export. It does not execute programs inside containers, extract archives, stop containers or change their files. It separately scans bind-mount sources and discovered Docker volumes, including unused volumes. Remote daemons are rejected. Volume plugins/network-backed volumes may need separate mounting and scanning. If using a local socket proxy to a remote daemon, do not use this script: mount paths must refer to this machine.
|
|
52
|
+
|
|
53
|
+
Stopped containers, unused image layers, RAM, kernel state and rootkits are not covered. ClamAV's configured/default size, archive and recursion limits still apply, including to large container TAR exports; a successful scan can miss content past those limits. Review ClamAV summaries and warnings in every log. This is a best-effort filesystem scan, not a comprehensive compromise assessment. Live files can change during scanning. Use a trusted rescue environment for suspected root-level compromise.
|
|
54
|
+
|
|
55
|
+
Exit status: 0 = no signature detections reported and no recorded orchestration errors; 1 = detections; 2 = incomplete/error (detections may also exist; always inspect the report). No deletion or quarantine is requested. Reports persist if Docker scanning fails or the scan is interrupted with Ctrl+C. A forced kill or power loss may lose the active stage, but earlier checkpoints remain.
|
|
56
|
+
|
|
57
|
+
Validation on the development Windows machine covers syntax and mocked orchestration only. A real Linux/Docker/ClamAV integration scan must be validated on your server before operational use.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
devshield_linux.py,sha256=-8FTc3FIYD4AmxuGxCT12izIPytpmnQ-YobyewYQ-xs,9343
|
|
2
|
+
devshield_scanner-0.1.0.dist-info/licenses/LICENSE,sha256=s-XxIzkqir-HOLNTj5GGiMGroYKQg0jNLePiE6l3RLA,1079
|
|
3
|
+
devshield_scanner-0.1.0.dist-info/METADATA,sha256=j8SlJK0iWM3yqlaDSjJ0Uk7osnDbW76uUNJG2wckCOs,4459
|
|
4
|
+
devshield_scanner-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
5
|
+
devshield_scanner-0.1.0.dist-info/entry_points.txt,sha256=ahqb5UoqMVnfLlu6dw2yCOdoE-Oqf2rzLz0w7j_iMNU,51
|
|
6
|
+
devshield_scanner-0.1.0.dist-info/top_level.txt,sha256=6JS_zDOzXEumr3xF-CcuEnhbdrYP0EkNpehijbeWSwE,16
|
|
7
|
+
devshield_scanner-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DevShield contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
devshield_linux
|