nxtsecure-openclaw 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 scorpion7slayer
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.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # Secure-your-openclaw
2
+
3
+ OpenClaw security audit skill with an integrated npm CLI.
4
+
5
+ Published and maintained by `scorpion7slayer`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g nxtsecure-openclaw
11
+ ```
12
+
13
+ Then use:
14
+
15
+ ```bash
16
+ nxtsecure openclaw help
17
+ ```
18
+
19
+ ## What this repository contains
20
+
21
+ - An OpenClaw skill in `skills/openclaw-security-audit/`
22
+ - A Node-based CLI exposed as `nxtsecure openclaw`
23
+ - Bash runners for audit, cron setup, and VirusTotal browser-assisted checks
24
+
25
+ ## What the skill checks
26
+
27
+ - Firewall enabled
28
+ - `fail2ban` active
29
+ - SSH hardened with key-only auth and a non-default port
30
+ - Unexpected listening ports
31
+ - Docker container allowlisting
32
+ - Disk usage threshold
33
+ - Failed login attempts in the last 24 hours
34
+ - Automatic security package updates
35
+ - VirusTotal review for URLs and files without using a VirusTotal API key
36
+
37
+ ## Quick start
38
+
39
+ 1. Create a local config:
40
+
41
+ ```bash
42
+ npm run nxtsecure -- openclaw config init --output ./openclaw-security-audit.conf
43
+ ```
44
+
45
+ 2. Review and edit the generated config.
46
+
47
+ 3. Run the audit:
48
+
49
+ ```bash
50
+ npm run nxtsecure -- openclaw audit --config ./openclaw-security-audit.conf
51
+ ```
52
+
53
+ 4. Install the nightly cron job at 23:00:
54
+
55
+ ```bash
56
+ npm run nxtsecure -- openclaw cron install --log ~/openclaw-security-audit.log
57
+ ```
58
+
59
+ ## CLI commands
60
+
61
+ ```bash
62
+ npm run nxtsecure -- openclaw help
63
+ npm run nxtsecure -- openclaw audit --config ./openclaw-security-audit.conf
64
+ npm run nxtsecure -- openclaw cron install --log ~/openclaw-security-audit.log
65
+ npm run nxtsecure -- openclaw vt url https://example.test
66
+ npm run nxtsecure -- openclaw vt file /path/to/sample.bin
67
+ ```
68
+
69
+ ## VirusTotal mode
70
+
71
+ This repository intentionally avoids the VirusTotal API.
72
+
73
+ - URL checks are prepared for the public VirusTotal website through the OpenClaw browser tool.
74
+ - File checks compute the SHA-256 locally and prepare the public VirusTotal report URL.
75
+ - If a file is flagged, the agent must ask the user whether to keep or remove it.
76
+
77
+ ## Repository layout
78
+
79
+ ```text
80
+ bin/nxtsecure.mjs
81
+ package.json
82
+ skills/openclaw-security-audit/SKILL.md
83
+ skills/openclaw-security-audit/references/openclaw-security-audit.conf.example
84
+ skills/openclaw-security-audit/scripts/install_cron.sh
85
+ skills/openclaw-security-audit/scripts/openclaw_security_audit.sh
86
+ skills/openclaw-security-audit/scripts/openclaw_virustotal_check.sh
87
+ ```
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from 'node:child_process';
4
+ import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
5
+ import { dirname, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const currentDir = dirname(fileURLToPath(import.meta.url));
9
+ const rootDir = resolve(currentDir, '..');
10
+ const skillDir = resolve(rootDir, 'skills', 'openclaw-security-audit');
11
+ const scriptsDir = resolve(skillDir, 'scripts');
12
+ const referencesDir = resolve(skillDir, 'references');
13
+
14
+ const paths = {
15
+ audit: resolve(scriptsDir, 'openclaw_security_audit.sh'),
16
+ cron: resolve(scriptsDir, 'install_cron.sh'),
17
+ vt: resolve(scriptsDir, 'openclaw_virustotal_check.sh'),
18
+ configExample: resolve(referencesDir, 'openclaw-security-audit.conf.example')
19
+ };
20
+
21
+ function printHelp() {
22
+ console.log(`nxtsecure openclaw
23
+
24
+ Usage:
25
+ nxtsecure help
26
+ nxtsecure openclaw help
27
+ nxtsecure openclaw audit [--config PATH]
28
+ nxtsecure openclaw cron install [--log PATH]
29
+ nxtsecure openclaw vt url <url> [--allow-uploads]
30
+ nxtsecure openclaw vt file <path> [--allow-uploads]
31
+ nxtsecure openclaw config init [--output PATH] [--force]
32
+ nxtsecure openclaw paths
33
+
34
+ Examples:
35
+ nxtsecure openclaw config init --output ./openclaw-security-audit.conf
36
+ nxtsecure openclaw audit --config ./openclaw-security-audit.conf
37
+ nxtsecure openclaw cron install --log ~/openclaw-security-audit.log
38
+ nxtsecure openclaw vt url https://example.test
39
+ nxtsecure openclaw vt file /tmp/sample.bin --allow-uploads
40
+ `);
41
+ }
42
+
43
+ function fail(message, exitCode = 1) {
44
+ console.error(`[ERROR] ${message}`);
45
+ process.exit(exitCode);
46
+ }
47
+
48
+ function runBashScript(scriptPath, args = [], env = {}) {
49
+ if (!existsSync(scriptPath)) {
50
+ fail(`Missing script: ${scriptPath}`);
51
+ }
52
+
53
+ const result = spawnSync('bash', [scriptPath, ...args], {
54
+ cwd: rootDir,
55
+ stdio: 'inherit',
56
+ env: { ...process.env, ...env }
57
+ });
58
+
59
+ if (result.error) {
60
+ fail(result.error.message);
61
+ }
62
+
63
+ process.exit(result.status ?? 1);
64
+ }
65
+
66
+ function takeOption(argv, optionName) {
67
+ const index = argv.indexOf(optionName);
68
+ if (index === -1) {
69
+ return undefined;
70
+ }
71
+ if (index === argv.length - 1) {
72
+ fail(`Missing value for ${optionName}`);
73
+ }
74
+ return argv[index + 1];
75
+ }
76
+
77
+ function hasFlag(argv, flagName) {
78
+ return argv.includes(flagName);
79
+ }
80
+
81
+ function withoutOption(argv, optionName) {
82
+ const index = argv.indexOf(optionName);
83
+ if (index === -1) {
84
+ return argv.slice();
85
+ }
86
+ const clone = argv.slice();
87
+ clone.splice(index, 2);
88
+ return clone;
89
+ }
90
+
91
+ function withoutFlag(argv, flagName) {
92
+ return argv.filter((item) => item !== flagName);
93
+ }
94
+
95
+ function commandAudit(argv) {
96
+ const configPath = takeOption(argv, '--config');
97
+ const args = configPath ? withoutOption(argv, '--config') : argv.slice();
98
+ if (args.length !== 0) {
99
+ fail(`Unknown audit arguments: ${args.join(' ')}`);
100
+ }
101
+ runBashScript(paths.audit, [], configPath ? { OPENCLAW_AUDIT_CONFIG: resolve(rootDir, configPath) } : {});
102
+ }
103
+
104
+ function commandCron(argv) {
105
+ if (argv[0] !== 'install') {
106
+ fail('Usage: nxtsecure openclaw cron install [--log PATH]');
107
+ }
108
+
109
+ const logPath = takeOption(argv, '--log');
110
+ const args = logPath ? withoutOption(argv.slice(1), '--log') : argv.slice(1);
111
+ if (args.length !== 0) {
112
+ fail(`Unknown cron arguments: ${args.join(' ')}`);
113
+ }
114
+
115
+ runBashScript(paths.cron, [], logPath ? { OPENCLAW_AUDIT_LOG: logPath } : {});
116
+ }
117
+
118
+ function commandVirusTotal(argv) {
119
+ const allowUploads = hasFlag(argv, '--allow-uploads');
120
+ const cleaned = allowUploads ? withoutFlag(argv, '--allow-uploads') : argv.slice();
121
+ const mode = cleaned[0];
122
+ const value = cleaned[1];
123
+
124
+ if (!mode || !value || cleaned.length !== 2 || !['url', 'file'].includes(mode)) {
125
+ fail('Usage: nxtsecure openclaw vt url <url> [--allow-uploads] | nxtsecure openclaw vt file <path> [--allow-uploads]');
126
+ }
127
+
128
+ const scriptArgs = mode === 'url' ? ['--url', value] : ['--file', value];
129
+ runBashScript(paths.vt, scriptArgs, {
130
+ VIRUSTOTAL_ALLOW_UPLOADS: allowUploads ? '1' : '0'
131
+ });
132
+ }
133
+
134
+ function commandConfig(argv) {
135
+ if (argv[0] !== 'init') {
136
+ fail('Usage: nxtsecure openclaw config init [--output PATH] [--force]');
137
+ }
138
+
139
+ const force = hasFlag(argv, '--force');
140
+ const output = takeOption(argv, '--output') ?? './openclaw-security-audit.conf';
141
+ const cleaned = force ? withoutFlag(argv.slice(1), '--force') : argv.slice(1);
142
+ const finalArgs = output ? withoutOption(cleaned, '--output') : cleaned;
143
+ if (finalArgs.length !== 0) {
144
+ fail(`Unknown config arguments: ${finalArgs.join(' ')}`);
145
+ }
146
+
147
+ const targetPath = resolve(rootDir, output);
148
+ if (existsSync(targetPath) && !force) {
149
+ fail(`Config already exists: ${targetPath}. Use --force to overwrite.`);
150
+ }
151
+
152
+ mkdirSync(dirname(targetPath), { recursive: true });
153
+ copyFileSync(paths.configExample, targetPath);
154
+ console.log(`Created config: ${targetPath}`);
155
+ }
156
+
157
+ function commandPaths(argv) {
158
+ if (argv.length !== 0) {
159
+ fail(`Unknown paths arguments: ${argv.join(' ')}`);
160
+ }
161
+
162
+ console.log(`root=${rootDir}`);
163
+ console.log(`skill=${skillDir}`);
164
+ console.log(`audit=${paths.audit}`);
165
+ console.log(`cron=${paths.cron}`);
166
+ console.log(`vt=${paths.vt}`);
167
+ console.log(`configExample=${paths.configExample}`);
168
+ }
169
+
170
+ function runOpenClaw(argv) {
171
+ const [command = 'help', ...rest] = argv;
172
+
173
+ switch (command) {
174
+ case 'help':
175
+ case '--help':
176
+ case '-h':
177
+ printHelp();
178
+ break;
179
+ case 'audit':
180
+ commandAudit(rest);
181
+ break;
182
+ case 'cron':
183
+ commandCron(rest);
184
+ break;
185
+ case 'vt':
186
+ commandVirusTotal(rest);
187
+ break;
188
+ case 'config':
189
+ commandConfig(rest);
190
+ break;
191
+ case 'paths':
192
+ commandPaths(rest);
193
+ break;
194
+ default:
195
+ fail(`Unknown openclaw command: ${command}`);
196
+ }
197
+ }
198
+
199
+ const [namespace = 'help', ...rest] = process.argv.slice(2);
200
+
201
+ switch (namespace) {
202
+ case 'help':
203
+ case '--help':
204
+ case '-h':
205
+ printHelp();
206
+ break;
207
+ case 'openclaw':
208
+ runOpenClaw(rest);
209
+ break;
210
+ default:
211
+ fail(`Unknown namespace: ${namespace}. Expected: openclaw`);
212
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "nxtsecure-openclaw",
3
+ "version": "0.1.0",
4
+ "description": "npm CLI wrapper for the OpenClaw security audit skill",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "nxtsecure": "bin/nxtsecure.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "skills",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/scorpion7slayer/Secure-your-openclaw.git"
19
+ },
20
+ "homepage": "https://github.com/scorpion7slayer/Secure-your-openclaw#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/scorpion7slayer/Secure-your-openclaw/issues"
23
+ },
24
+ "keywords": [
25
+ "openclaw",
26
+ "security",
27
+ "audit",
28
+ "cli",
29
+ "virustotal",
30
+ "ssh"
31
+ ],
32
+ "scripts": {
33
+ "nxtsecure": "node ./bin/nxtsecure.mjs",
34
+ "test": "node ./bin/nxtsecure.mjs help"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ }
39
+ }
@@ -0,0 +1,104 @@
1
+ ---
2
+ name: openclaw-security-audit
3
+ description: Use when auditing and remediating an OpenClaw Linux host with a nightly 23:00 security run. Covers firewall status, fail2ban bans, SSH hardening with key-only auth and a non-default port, unexpected listening ports, Docker container allowlisting, disk usage under 80%, failed login attempts in the last 24 hours, automatic security package updates, VirusTotal browser-based checks for URLs and files without API keys, and installing the cron entry.
4
+ homepage: https://openclaw.ai/
5
+ ---
6
+
7
+ # OpenClaw Security Audit
8
+
9
+ Original requested prompt, preserved verbatim:
10
+ "Effectuez un audit de sécurité tous les soirs à 23h faite un cron."
11
+
12
+ Use this skill when the user wants a repeatable OpenClaw host security audit, a nightly cron job, or immediate remediation of common hardening gaps.
13
+
14
+ ## Workflow
15
+
16
+ 1. Prefer the npm CLI in `{baseDir}/../../bin/nxtsecure.mjs` to keep the agent workflow stable.
17
+ 2. From the repository root, create the local configuration with `npm run nxtsecure -- openclaw config init --output ./openclaw-security-audit.conf` or copy `{baseDir}/references/openclaw-security-audit.conf.example`.
18
+ 3. Run `npm run nxtsecure -- openclaw audit --config ./openclaw-security-audit.conf` to execute the audit and remediation workflow.
19
+ 4. Install the nightly 23:00 cron entry with `npm run nxtsecure -- openclaw cron install --log ~/openclaw-security-audit.log`.
20
+ 5. If every check passes, print exactly `audit de sécurité réussi`.
21
+ 6. If a check fails, explain the issue, attempt remediation immediately, and rerun the relevant verification.
22
+
23
+ ## Checks
24
+
25
+ The audit must verify:
26
+
27
+ 1. Firewall enabled.
28
+ 2. `fail2ban` active and total banned IP count collected.
29
+ 3. If SSH is used, password authentication must be disabled, public key authentication must be available, and the SSH service must not listen on port `22`.
30
+ 4. Unexpected listening ports identified and, when configured, blocked.
31
+ 5. Docker containers reviewed when Docker is present, with unexpected containers stopped only when an allowlist is configured.
32
+ 6. Disk usage below `80%` on persistent filesystems.
33
+ 7. Failed login attempts during the last 24 hours.
34
+ 8. Automatic security package updates enabled on the host.
35
+ 9. If VirusTotal is configured, URLs and files in scope must be checked before being trusted.
36
+
37
+ ## SSH hardening guidance
38
+
39
+ When SSH is enabled, the agent must help the user migrate safely instead of changing access blindly.
40
+
41
+ 1. Explain the goal: SSH on a non-default port and key-only authentication.
42
+ 2. Ask or infer the target SSH port from configuration. Use `2222` only as a fallback example, not a forced default.
43
+ 3. Help the user generate a key pair if needed:
44
+ `ssh-keygen -t ed25519 -C "openclaw-admin"`
45
+ 4. Help the user install the public key on the server:
46
+ `ssh-copy-id -p <new-port> <user>@<host>`
47
+ or append the public key to `~/.ssh/authorized_keys` with correct permissions.
48
+ 5. Update SSH to use the chosen non-default port and disable password authentication.
49
+ 6. Make sure the firewall allows the new SSH port before reloading SSH.
50
+ 7. Tell the user to open a second terminal and verify:
51
+ `ssh -p <new-port> <user>@<host>`
52
+ 8. Only after the new key-based login works, remove any temporary legacy access and confirm the hardening is complete.
53
+
54
+ If the agent cannot verify that key-based access on the new port works, it must explain the exact manual steps still required and avoid risky lockout actions.
55
+
56
+ ## VirusTotal guidance
57
+
58
+ When the user wants file or link reputation checks, the agent must use VirusTotal without an API key:
59
+
60
+ 1. Use the OpenClaw `browser` tool, not the VirusTotal API.
61
+ 2. Ensure the OpenClaw browser tool is enabled before starting the workflow.
62
+ 3. For files, compute the SHA-256 locally first and prefer the public report page for an existing report.
63
+ 4. Only upload a file through the VirusTotal website when the user has explicitly allowed it, because website uploads may disclose the sample outside the organization.
64
+ 5. For URLs, open the public VirusTotal URL page in the browser tool and submit the URL for analysis through the web interface.
65
+ 6. If a file or URL is malicious, explain the verdict. For files, ask the user whether to keep or remove the file. For URLs, recommend blocking the URL or domain.
66
+ 7. If an item is suspicious, explain the risk and require explicit user confirmation before trusting it.
67
+ 8. For nightly automation, treat VirusTotal as browser-assisted review.
68
+ 9. If VirusTotal flags a file as malicious or suspicious, the agent must ask the user whether to keep or remove the file. The user always decides.
69
+ 10. Do not claim that a URL or file was cleared automatically when the agent has only prepared the VirusTotal browser workflow and not inspected the result page.
70
+
71
+ Use the bundled helper:
72
+
73
+ - `npm run nxtsecure -- openclaw vt url https://example.test`
74
+ - `npm run nxtsecure -- openclaw vt file /path/to/sample.bin`
75
+ - fallback: `{baseDir}/scripts/openclaw_virustotal_check.sh --url https://example.test`
76
+ - fallback: `{baseDir}/scripts/openclaw_virustotal_check.sh --file /path/to/sample.bin`
77
+
78
+ OpenClaw browser flow:
79
+
80
+ 1. `browser.start`
81
+ 2. `browser.open` or `browser.navigate` to `https://www.virustotal.com/gui/home/url` for URLs
82
+ 3. `browser.open` or `browser.navigate` to `https://www.virustotal.com/gui/home/upload` for files
83
+ 4. Use `browser.snapshot` and `browser.act` to type, upload, and inspect detection results
84
+
85
+ ## Operational notes
86
+
87
+ - Run the audit as `root` when possible. Some remediations require privileged access.
88
+ - Adjust expected ports and allowed Docker containers before enabling strict enforcement.
89
+ - The bundled script prefers `ufw`, then `firewalld`, then a non-empty `nftables` ruleset for firewall detection.
90
+ - The script uses `sshd -T` when available and falls back to SSH config files.
91
+ - The bundled SSH policy expects a non-default port whenever SSH is enabled. Port `22` is treated as non-compliant.
92
+ - The audit should enable automatic security updates when supported by the distribution, such as `unattended-upgrades` on Debian or Ubuntu and `dnf-automatic` on RPM-based hosts.
93
+ - Failed logins are collected from `journalctl`, `lastb`, or `/var/log/auth.log`, depending on what the host exposes.
94
+ - VirusTotal checks in this skill are intentionally API-free and rely on the public website plus the OpenClaw browser tool.
95
+ - The nightly cron line installed by the helper is `0 23 * * *`.
96
+
97
+ ## Files
98
+
99
+ - `{baseDir}/../../package.json`: npm package definition for the `nxtsecure openclaw` CLI.
100
+ - `{baseDir}/../../bin/nxtsecure.mjs`: npm CLI entrypoint for audit, cron, VirusTotal, and config init.
101
+ - `{baseDir}/scripts/openclaw_security_audit.sh`: audit and remediation runner.
102
+ - `{baseDir}/scripts/openclaw_virustotal_check.sh`: VirusTotal URL and file reputation helper.
103
+ - `{baseDir}/scripts/install_cron.sh`: idempotent cron installer for `23:00` every day.
104
+ - `{baseDir}/references/openclaw-security-audit.conf.example`: baseline configuration template.
@@ -0,0 +1,36 @@
1
+ # Copy this file to:
2
+ # - /etc/openclaw-security-audit.conf
3
+ # or point the audit to it with OPENCLAW_AUDIT_CONFIG=/path/to/file
4
+
5
+ # Expected listening ports. Do not keep 22 here if SSH is enabled.
6
+ # Include the chosen SSH_EXPECTED_PORT instead.
7
+ EXPECTED_TCP_PORTS="80 443 2222"
8
+ EXPECTED_UDP_PORTS=""
9
+
10
+ # SSH policy. If SSH is enabled, it must not use port 22.
11
+ # Set SSH_EXPECTED_PORT to the chosen non-default port, for example 2222.
12
+ SSH_EXPECTED_PORT="2222"
13
+ SSH_PORT_IN_USE=1
14
+ SSH_REQUIRE_KEYS_ONLY=1
15
+ ENABLE_SSH_PORT_REMEDIATION=0
16
+
17
+ # VirusTotal integration for URLs and files, without API keys.
18
+ # The agent should use the public VirusTotal website through the OpenClaw browser tool.
19
+ VIRUSTOTAL_ENABLED=0
20
+ VIRUSTOTAL_ALLOW_UPLOADS=0
21
+ VIRUSTOTAL_URLS=""
22
+ VIRUSTOTAL_FILES=""
23
+
24
+ # List allowed Docker container names, separated by spaces.
25
+ # Leave empty to force the audit to report unmanaged running containers.
26
+ ALLOWED_DOCKER_CONTAINERS=""
27
+
28
+ # Security thresholds.
29
+ DISK_THRESHOLD_PERCENT=80
30
+ MAX_FAILED_LOGIN_ATTEMPTS=25
31
+ AUTO_SECURITY_UPDATES_REQUIRED=1
32
+
33
+ # Remediation controls.
34
+ AUTO_REMEDIATE=1
35
+ REMEDIATE_UNEXPECTED_PORTS=0
36
+ ALLOW_DOCKER_PRUNE=0
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ AUDIT_SCRIPT="${SCRIPT_DIR}/openclaw_security_audit.sh"
7
+ LOG_PATH="${OPENCLAW_AUDIT_LOG:-${HOME}/openclaw-security-audit.log}"
8
+
9
+ if [[ ! -x "${AUDIT_SCRIPT}" ]]; then
10
+ chmod +x "${AUDIT_SCRIPT}"
11
+ fi
12
+
13
+ START_MARKER="# OPENCLAW_SECURITY_AUDIT_START"
14
+ END_MARKER="# OPENCLAW_SECURITY_AUDIT_END"
15
+ CRON_LINE="0 23 * * * /bin/bash ${AUDIT_SCRIPT} >> ${LOG_PATH} 2>&1"
16
+
17
+ CURRENT_CRONTAB="$(mktemp)"
18
+ UPDATED_CRONTAB="$(mktemp)"
19
+
20
+ trap 'rm -f "${CURRENT_CRONTAB}" "${UPDATED_CRONTAB}"' EXIT
21
+
22
+ crontab -l 2>/dev/null > "${CURRENT_CRONTAB}" || true
23
+ awk -v start="${START_MARKER}" -v end="${END_MARKER}" '
24
+ $0 == start { skip = 1; next }
25
+ $0 == end { skip = 0; next }
26
+ skip != 1 { print }
27
+ ' "${CURRENT_CRONTAB}" > "${UPDATED_CRONTAB}"
28
+
29
+ {
30
+ cat "${UPDATED_CRONTAB}"
31
+ printf '%s\n' "${START_MARKER}"
32
+ printf '%s\n' "${CRON_LINE}"
33
+ printf '%s\n' "${END_MARKER}"
34
+ } | crontab -
35
+
36
+ printf 'Installed nightly cron at 23:00.\n'
37
+ printf 'Log file: %s\n' "${LOG_PATH}"
@@ -0,0 +1,752 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ SKILL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
7
+
8
+ CONFIG_FILE="${OPENCLAW_AUDIT_CONFIG:-}"
9
+ if [[ -z "${CONFIG_FILE}" ]]; then
10
+ for candidate in \
11
+ "/etc/openclaw-security-audit.conf" \
12
+ "${SKILL_DIR}/references/openclaw-security-audit.conf" \
13
+ "${SKILL_DIR}/references/openclaw-security-audit.conf.example"
14
+ do
15
+ if [[ -f "${candidate}" ]]; then
16
+ CONFIG_FILE="${candidate}"
17
+ break
18
+ fi
19
+ done
20
+ fi
21
+
22
+ EXPECTED_TCP_PORTS="80 443 2222"
23
+ EXPECTED_UDP_PORTS=""
24
+ SSH_EXPECTED_PORT="2222"
25
+ SSH_PORT_IN_USE=1
26
+ SSH_REQUIRE_KEYS_ONLY=1
27
+ ENABLE_SSH_PORT_REMEDIATION=0
28
+ VIRUSTOTAL_ENABLED=0
29
+ VIRUSTOTAL_ALLOW_UPLOADS=0
30
+ VIRUSTOTAL_URLS=""
31
+ VIRUSTOTAL_FILES=""
32
+ ALLOWED_DOCKER_CONTAINERS=""
33
+ DISK_THRESHOLD_PERCENT=80
34
+ MAX_FAILED_LOGIN_ATTEMPTS=25
35
+ AUTO_SECURITY_UPDATES_REQUIRED=1
36
+ AUTO_REMEDIATE=1
37
+ REMEDIATE_UNEXPECTED_PORTS=0
38
+ ALLOW_DOCKER_PRUNE=0
39
+
40
+ if [[ -n "${CONFIG_FILE}" && -f "${CONFIG_FILE}" ]]; then
41
+ # shellcheck disable=SC1090
42
+ source "${CONFIG_FILE}"
43
+ fi
44
+
45
+ declare -a ISSUES=()
46
+ declare -a ACTIONS=()
47
+ declare -a WARNINGS=()
48
+
49
+ log() {
50
+ printf '[INFO] %s\n' "$*"
51
+ }
52
+
53
+ warn() {
54
+ WARNINGS+=("$*")
55
+ printf '[WARN] %s\n' "$*"
56
+ }
57
+
58
+ issue() {
59
+ ISSUES+=("$*")
60
+ printf '[FAIL] %s\n' "$*"
61
+ }
62
+
63
+ action() {
64
+ ACTIONS+=("$*")
65
+ printf '[FIX] %s\n' "$*"
66
+ }
67
+
68
+ command_exists() {
69
+ command -v "$1" >/dev/null 2>&1
70
+ }
71
+
72
+ is_root() {
73
+ [[ "${EUID}" -eq 0 ]]
74
+ }
75
+
76
+ run_privileged() {
77
+ if is_root; then
78
+ "$@"
79
+ elif command_exists sudo && sudo -n true >/dev/null 2>&1; then
80
+ sudo "$@"
81
+ else
82
+ return 126
83
+ fi
84
+ }
85
+
86
+ contains_word() {
87
+ local needle="$1"
88
+ local haystack="$2"
89
+ local item
90
+ for item in ${haystack}; do
91
+ if [[ "${item}" == "${needle}" ]]; then
92
+ return 0
93
+ fi
94
+ done
95
+ return 1
96
+ }
97
+
98
+ if [[ "${SSH_PORT_IN_USE}" -eq 1 ]] && ! contains_word "${SSH_EXPECTED_PORT}" "${EXPECTED_TCP_PORTS}"; then
99
+ EXPECTED_TCP_PORTS="${EXPECTED_TCP_PORTS} ${SSH_EXPECTED_PORT}"
100
+ fi
101
+
102
+ service_is_active() {
103
+ local service="$1"
104
+ command_exists systemctl && systemctl is-active --quiet "${service}"
105
+ }
106
+
107
+ safe_systemctl_enable_now() {
108
+ local service="$1"
109
+ if run_privileged systemctl enable --now "${service}" >/dev/null 2>&1; then
110
+ action "Enabled ${service}."
111
+ return 0
112
+ fi
113
+ return 1
114
+ }
115
+
116
+ detect_firewall_backend() {
117
+ if command_exists ufw; then
118
+ echo "ufw"
119
+ return 0
120
+ fi
121
+ if command_exists firewall-cmd; then
122
+ echo "firewalld"
123
+ return 0
124
+ fi
125
+ if command_exists nft; then
126
+ echo "nftables"
127
+ return 0
128
+ fi
129
+ echo "none"
130
+ }
131
+
132
+ check_firewall() {
133
+ local backend
134
+ backend="$(detect_firewall_backend)"
135
+
136
+ case "${backend}" in
137
+ ufw)
138
+ if ufw status 2>/dev/null | grep -q "^Status: active"; then
139
+ log "Firewall active via ufw."
140
+ elif [[ "${AUTO_REMEDIATE}" -eq 1 ]] && run_privileged ufw --force enable >/dev/null 2>&1; then
141
+ action "Enabled ufw."
142
+ else
143
+ issue "ufw is installed but not active."
144
+ fi
145
+ ;;
146
+ firewalld)
147
+ if service_is_active firewalld; then
148
+ log "Firewall active via firewalld."
149
+ elif [[ "${AUTO_REMEDIATE}" -eq 1 ]] && safe_systemctl_enable_now firewalld; then
150
+ :
151
+ else
152
+ issue "firewalld is installed but not active."
153
+ fi
154
+ ;;
155
+ nftables)
156
+ if nft list ruleset 2>/dev/null | grep -q "table"; then
157
+ log "Firewall appears active via nftables."
158
+ else
159
+ issue "nftables is present but no active ruleset was found."
160
+ fi
161
+ ;;
162
+ *)
163
+ issue "No supported firewall manager was found."
164
+ ;;
165
+ esac
166
+ }
167
+
168
+ fail2ban_banned_total() {
169
+ local total=0
170
+ local jail
171
+ local jails
172
+
173
+ if ! command_exists fail2ban-client; then
174
+ echo "0"
175
+ return 0
176
+ fi
177
+
178
+ jails="$(fail2ban-client status 2>/dev/null | sed -n 's/.*Jail list:[[:space:]]*//p' | tr ',' ' ')"
179
+ if [[ -z "${jails}" ]]; then
180
+ echo "0"
181
+ return 0
182
+ fi
183
+
184
+ for jail in ${jails}; do
185
+ local count
186
+ count="$(fail2ban-client status "${jail}" 2>/dev/null | sed -n 's/.*Total banned:[[:space:]]*//p' | tail -n1)"
187
+ if [[ "${count}" =~ ^[0-9]+$ ]]; then
188
+ total=$((total + count))
189
+ fi
190
+ done
191
+
192
+ echo "${total}"
193
+ }
194
+
195
+ check_fail2ban() {
196
+ if ! command_exists fail2ban-client && ! service_is_active fail2ban; then
197
+ issue "fail2ban is not installed or not available."
198
+ return 0
199
+ fi
200
+
201
+ if service_is_active fail2ban; then
202
+ log "fail2ban is active. Total banned IPs: $(fail2ban_banned_total)."
203
+ elif [[ "${AUTO_REMEDIATE}" -eq 1 ]] && safe_systemctl_enable_now fail2ban; then
204
+ log "fail2ban is active. Total banned IPs: $(fail2ban_banned_total)."
205
+ else
206
+ issue "fail2ban is installed but inactive."
207
+ fi
208
+ }
209
+
210
+ ssh_effective_value() {
211
+ local key="$1"
212
+ if command_exists sshd; then
213
+ sshd -T 2>/dev/null | awk -v target="${key}" '$1 == target { print $2; exit }'
214
+ return 0
215
+ fi
216
+ awk -v target="${key}" '
217
+ BEGIN { IGNORECASE=1 }
218
+ $1 == target { value = $2 }
219
+ END { if (value != "") print tolower(value) }
220
+ ' /etc/ssh/sshd_config 2>/dev/null
221
+ }
222
+
223
+ remediate_ssh_password_auth() {
224
+ local drop_in="/etc/ssh/sshd_config.d/99-openclaw-hardening.conf"
225
+ local tmp_file
226
+
227
+ tmp_file="$(mktemp)"
228
+ cat >"${tmp_file}" <<'EOF'
229
+ PasswordAuthentication no
230
+ KbdInteractiveAuthentication no
231
+ ChallengeResponseAuthentication no
232
+ EOF
233
+
234
+ if run_privileged mkdir -p /etc/ssh/sshd_config.d \
235
+ && run_privileged install -m 0644 "${tmp_file}" "${drop_in}" \
236
+ && run_privileged sshd -t \
237
+ && (run_privileged systemctl reload sshd >/dev/null 2>&1 || run_privileged systemctl reload ssh >/dev/null 2>&1); then
238
+ rm -f "${tmp_file}"
239
+ action "Disabled SSH password authentication with ${drop_in}."
240
+ return 0
241
+ fi
242
+
243
+ rm -f "${tmp_file}"
244
+ return 1
245
+ }
246
+
247
+ ssh_effective_ports() {
248
+ if command_exists sshd; then
249
+ sshd -T 2>/dev/null | awk '$1 == "port" { print $2 }'
250
+ return 0
251
+ fi
252
+
253
+ awk '
254
+ BEGIN { IGNORECASE=1 }
255
+ $1 == "Port" { print $2 }
256
+ ' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf 2>/dev/null
257
+ }
258
+
259
+ ssh_key_auth_enabled() {
260
+ local key_auth
261
+ key_auth="$(ssh_effective_value pubkeyauthentication)"
262
+ [[ -z "${key_auth}" || "${key_auth}" == "yes" ]]
263
+ }
264
+
265
+ remediate_ssh_port() {
266
+ local desired_port="$1"
267
+ local drop_in="/etc/ssh/sshd_config.d/98-openclaw-port.conf"
268
+ local tmp_file
269
+
270
+ if [[ -z "${desired_port}" || "${desired_port}" == "22" ]]; then
271
+ return 1
272
+ fi
273
+
274
+ tmp_file="$(mktemp)"
275
+ cat >"${tmp_file}" <<EOF
276
+ Port ${desired_port}
277
+ EOF
278
+
279
+ if ! run_privileged mkdir -p /etc/ssh/sshd_config.d; then
280
+ rm -f "${tmp_file}"
281
+ return 1
282
+ fi
283
+
284
+ if ! run_privileged install -m 0644 "${tmp_file}" "${drop_in}"; then
285
+ rm -f "${tmp_file}"
286
+ return 1
287
+ fi
288
+
289
+ rm -f "${tmp_file}"
290
+
291
+ if ! run_privileged sshd -t; then
292
+ return 1
293
+ fi
294
+
295
+ if ! run_privileged systemctl reload sshd >/dev/null 2>&1 \
296
+ && ! run_privileged systemctl reload ssh >/dev/null 2>&1; then
297
+ return 1
298
+ fi
299
+
300
+ action "Configured SSH to listen on port ${desired_port}. Verify access with: ssh -p ${desired_port} <user>@<host>"
301
+ return 0
302
+ }
303
+
304
+ check_ssh() {
305
+ local password_auth
306
+ local ports
307
+ local port_ok=1
308
+ password_auth="$(ssh_effective_value passwordauthentication)"
309
+
310
+ if [[ "${SSH_REQUIRE_KEYS_ONLY}" -eq 1 ]]; then
311
+ if [[ "${password_auth}" != "no" ]]; then
312
+ if [[ "${AUTO_REMEDIATE}" -eq 1 ]] && remediate_ssh_password_auth; then
313
+ password_auth="$(ssh_effective_value passwordauthentication)"
314
+ fi
315
+ fi
316
+
317
+ if [[ "${password_auth}" != "no" ]]; then
318
+ issue "SSH password authentication is still enabled."
319
+ elif ! ssh_key_auth_enabled; then
320
+ issue "SSH public key authentication is not enabled."
321
+ else
322
+ log "SSH is configured for key-based authentication only."
323
+ fi
324
+
325
+ else
326
+ log "SSH key-only enforcement is disabled by configuration."
327
+ fi
328
+
329
+ if [[ "${SSH_PORT_IN_USE}" -ne 1 ]]; then
330
+ log "SSH port policy skipped because SSH_PORT_IN_USE is disabled."
331
+ return 0
332
+ fi
333
+
334
+ ports="$(ssh_effective_ports | sort -u | tr '\n' ' ')"
335
+ if [[ -z "${ports}" ]]; then
336
+ warn "Unable to determine SSH listening ports from configuration."
337
+ return 0
338
+ fi
339
+
340
+ if contains_word "22" "${ports}"; then
341
+ port_ok=0
342
+ if [[ "${AUTO_REMEDIATE}" -eq 1 && "${ENABLE_SSH_PORT_REMEDIATION}" -eq 1 ]] \
343
+ && remediate_ssh_port "${SSH_EXPECTED_PORT}"; then
344
+ ports="$(ssh_effective_ports | sort -u | tr '\n' ' ')"
345
+ if contains_word "22" "${ports}"; then
346
+ issue "SSH still exposes port 22 after remediation attempt."
347
+ else
348
+ log "SSH no longer uses port 22."
349
+ fi
350
+ else
351
+ issue "SSH is configured on port 22. Migrate to a non-default port such as ${SSH_EXPECTED_PORT}, allow it in the firewall, then verify with: ssh -p ${SSH_EXPECTED_PORT} <user>@<host>"
352
+ fi
353
+ fi
354
+
355
+ if ! contains_word "${SSH_EXPECTED_PORT}" "${ports}"; then
356
+ port_ok=0
357
+ issue "SSH is not configured on the expected port ${SSH_EXPECTED_PORT}. Current configured ports: ${ports}"
358
+ fi
359
+
360
+ if [[ "${port_ok}" -eq 1 ]]; then
361
+ log "SSH uses the expected non-default port ${SSH_EXPECTED_PORT}."
362
+ fi
363
+ }
364
+
365
+ list_listening_ports() {
366
+ ss -lntuH 2>/dev/null | awk '
367
+ {
368
+ split($5, parts, ":")
369
+ port = parts[length(parts)]
370
+ if (port ~ /^[0-9]+$/) {
371
+ print tolower($1) " " port
372
+ }
373
+ }
374
+ ' | sort -u
375
+ }
376
+
377
+ block_port() {
378
+ local proto="$1"
379
+ local port="$2"
380
+ local backend
381
+ backend="$(detect_firewall_backend)"
382
+
383
+ case "${backend}" in
384
+ ufw)
385
+ run_privileged ufw deny "${port}/${proto}" >/dev/null 2>&1
386
+ ;;
387
+ firewalld)
388
+ run_privileged firewall-cmd --permanent --remove-port="${port}/${proto}" >/dev/null 2>&1 \
389
+ && run_privileged firewall-cmd --reload >/dev/null 2>&1
390
+ ;;
391
+ *)
392
+ return 1
393
+ ;;
394
+ esac
395
+ }
396
+
397
+ check_ports() {
398
+ local proto
399
+ local port
400
+ local unexpected=0
401
+ local current
402
+
403
+ while read -r proto port; do
404
+ [[ -z "${proto}" || -z "${port}" ]] && continue
405
+
406
+ if [[ "${proto}" == udp* ]]; then
407
+ current="${EXPECTED_UDP_PORTS}"
408
+ proto="udp"
409
+ else
410
+ current="${EXPECTED_TCP_PORTS}"
411
+ proto="tcp"
412
+ fi
413
+
414
+ if contains_word "${port}" "${current}"; then
415
+ continue
416
+ fi
417
+
418
+ unexpected=1
419
+ if [[ "${AUTO_REMEDIATE}" -eq 1 && "${REMEDIATE_UNEXPECTED_PORTS}" -eq 1 ]] && block_port "${proto}" "${port}"; then
420
+ action "Blocked unexpected ${proto} port ${port} at the firewall."
421
+ else
422
+ issue "Unexpected listening ${proto} port detected: ${port}."
423
+ fi
424
+ done < <(list_listening_ports)
425
+
426
+ if [[ "${unexpected}" -eq 0 ]]; then
427
+ log "No unexpected listening ports detected."
428
+ fi
429
+ }
430
+
431
+ check_docker() {
432
+ local running
433
+ local name
434
+ local unexpected=0
435
+
436
+ if ! command_exists docker; then
437
+ log "Docker is not installed."
438
+ return 0
439
+ fi
440
+
441
+ if ! docker info >/dev/null 2>&1; then
442
+ warn "Docker is installed but unavailable to the current user."
443
+ return 0
444
+ fi
445
+
446
+ running="$(docker ps --format '{{.Names}}' 2>/dev/null)"
447
+ if [[ -z "${running}" ]]; then
448
+ log "No running Docker containers."
449
+ return 0
450
+ fi
451
+
452
+ if [[ -z "${ALLOWED_DOCKER_CONTAINERS}" ]]; then
453
+ issue "Running Docker containers found but ALLOWED_DOCKER_CONTAINERS is not configured."
454
+ printf '%s\n' "${running}" | sed 's/^/[INFO] Running container: /'
455
+ return 0
456
+ fi
457
+
458
+ while read -r name; do
459
+ [[ -z "${name}" ]] && continue
460
+ if contains_word "${name}" "${ALLOWED_DOCKER_CONTAINERS}"; then
461
+ continue
462
+ fi
463
+
464
+ unexpected=1
465
+ if [[ "${AUTO_REMEDIATE}" -eq 1 ]] && docker stop "${name}" >/dev/null 2>&1; then
466
+ action "Stopped unexpected Docker container ${name}."
467
+ else
468
+ issue "Unexpected Docker container detected: ${name}."
469
+ fi
470
+ done <<< "${running}"
471
+
472
+ if [[ "${unexpected}" -eq 0 ]]; then
473
+ log "Docker containers match the allowlist."
474
+ fi
475
+ }
476
+
477
+ cleanup_disk_space() {
478
+ command_exists journalctl && run_privileged journalctl --vacuum-time=7d >/dev/null 2>&1 || true
479
+ command_exists apt-get && run_privileged apt-get clean >/dev/null 2>&1 || true
480
+ command_exists dnf && run_privileged dnf clean all >/dev/null 2>&1 || true
481
+ command_exists yum && run_privileged yum clean all >/dev/null 2>&1 || true
482
+ if [[ "${ALLOW_DOCKER_PRUNE}" -eq 1 ]] && command_exists docker; then
483
+ docker system prune -af >/dev/null 2>&1 || true
484
+ fi
485
+ }
486
+
487
+ enable_auto_security_updates_apt() {
488
+ local periodic_file="/etc/apt/apt.conf.d/20auto-upgrades"
489
+ local origin_file="/etc/apt/apt.conf.d/52openclaw-unattended-upgrades"
490
+ local distro_id version_codename tmp_file
491
+
492
+ distro_id="$(. /etc/os-release 2>/dev/null; printf '%s' "${ID:-Ubuntu}")"
493
+ version_codename="$(. /etc/os-release 2>/dev/null; printf '%s' "${VERSION_CODENAME:-}")"
494
+ [[ -n "${version_codename}" ]] || version_codename="stable"
495
+
496
+ if ! command_exists apt-get; then
497
+ return 1
498
+ fi
499
+
500
+ run_privileged apt-get update >/dev/null 2>&1 || true
501
+ if ! dpkg -s unattended-upgrades >/dev/null 2>&1; then
502
+ run_privileged apt-get install -y unattended-upgrades >/dev/null 2>&1 || return 1
503
+ fi
504
+
505
+ tmp_file="$(mktemp)"
506
+ cat >"${tmp_file}" <<'EOF'
507
+ APT::Periodic::Update-Package-Lists "1";
508
+ APT::Periodic::Unattended-Upgrade "1";
509
+ EOF
510
+ run_privileged install -m 0644 "${tmp_file}" "${periodic_file}" || { rm -f "${tmp_file}"; return 1; }
511
+
512
+ cat >"${tmp_file}" <<EOF
513
+ Unattended-Upgrade::Origins-Pattern {
514
+ "origin=${distro_id},codename=${version_codename},label=${distro_id}-Security";
515
+ "origin=${distro_id},codename=${version_codename},suite=${version_codename}-security";
516
+ "origin=Debian,codename=${version_codename},label=Debian-Security";
517
+ "origin=Debian,codename=${version_codename},suite=${version_codename}-security";
518
+ "origin=Ubuntu,codename=${version_codename},archive=${version_codename}-security";
519
+ };
520
+ EOF
521
+ run_privileged install -m 0644 "${tmp_file}" "${origin_file}" || { rm -f "${tmp_file}"; return 1; }
522
+ rm -f "${tmp_file}"
523
+
524
+ if command_exists systemctl; then
525
+ run_privileged systemctl enable --now unattended-upgrades >/dev/null 2>&1 || true
526
+ fi
527
+
528
+ action "Enabled unattended-upgrades for security updates."
529
+ return 0
530
+ }
531
+
532
+ enable_auto_security_updates_dnf() {
533
+ local config_file="/etc/dnf/automatic.conf"
534
+ local timer_service=""
535
+ local tmp_file
536
+
537
+ if ! command_exists dnf; then
538
+ return 1
539
+ fi
540
+
541
+ run_privileged dnf -y install dnf-automatic >/dev/null 2>&1 || return 1
542
+
543
+ if command_exists systemctl; then
544
+ if systemctl list-unit-files 2>/dev/null | grep -q '^dnf-automatic-install.timer'; then
545
+ timer_service="dnf-automatic-install.timer"
546
+ elif systemctl list-unit-files 2>/dev/null | grep -q '^dnf-automatic.timer'; then
547
+ timer_service="dnf-automatic.timer"
548
+ fi
549
+ fi
550
+
551
+ if [[ -f "${config_file}" ]]; then
552
+ tmp_file="$(mktemp)"
553
+ awk '
554
+ /^\s*upgrade_type\s*=/ { print "upgrade_type = security"; next }
555
+ /^\s*apply_updates\s*=/ { print "apply_updates = yes"; next }
556
+ { print }
557
+ ' "${config_file}" > "${tmp_file}"
558
+ run_privileged install -m 0644 "${tmp_file}" "${config_file}" || { rm -f "${tmp_file}"; return 1; }
559
+ rm -f "${tmp_file}"
560
+ fi
561
+
562
+ [[ -n "${timer_service}" ]] || return 1
563
+ run_privileged systemctl enable --now "${timer_service}" >/dev/null 2>&1 || return 1
564
+ action "Enabled dnf-automatic security updates."
565
+ return 0
566
+ }
567
+
568
+ check_auto_security_updates() {
569
+ if [[ "${AUTO_SECURITY_UPDATES_REQUIRED}" -ne 1 ]]; then
570
+ log "Automatic security updates check is disabled by configuration."
571
+ return 0
572
+ fi
573
+
574
+ if command_exists apt-config || command_exists apt-get; then
575
+ if [[ -f /etc/apt/apt.conf.d/20auto-upgrades ]] \
576
+ && grep -Eq 'APT::Periodic::Unattended-Upgrade "1"' /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null \
577
+ && dpkg -s unattended-upgrades >/dev/null 2>&1; then
578
+ log "Automatic security updates are enabled via unattended-upgrades."
579
+ return 0
580
+ fi
581
+
582
+ if [[ "${AUTO_REMEDIATE}" -eq 1 ]] && enable_auto_security_updates_apt; then
583
+ return 0
584
+ fi
585
+
586
+ issue "Automatic security updates are not enabled on this APT host."
587
+ return 0
588
+ fi
589
+
590
+ if command_exists dnf; then
591
+ if command_exists systemctl \
592
+ && (systemctl is-enabled --quiet dnf-automatic-install.timer 2>/dev/null || systemctl is-enabled --quiet dnf-automatic.timer 2>/dev/null) \
593
+ && grep -Eq '^\s*upgrade_type\s*=\s*security' /etc/dnf/automatic.conf 2>/dev/null; then
594
+ log "Automatic security updates are enabled via dnf-automatic."
595
+ return 0
596
+ fi
597
+
598
+ if [[ "${AUTO_REMEDIATE}" -eq 1 ]] && enable_auto_security_updates_dnf; then
599
+ return 0
600
+ fi
601
+
602
+ issue "Automatic security updates are not enabled on this DNF host."
603
+ return 0
604
+ fi
605
+
606
+ warn "Automatic security update verification is not implemented for this package manager."
607
+ }
608
+
609
+ check_disk() {
610
+ local line
611
+ local usage
612
+ local mountpoint
613
+ local over_limit=0
614
+
615
+ while read -r line; do
616
+ usage="$(awk '{print $5}' <<< "${line}" | tr -d '%')"
617
+ mountpoint="$(awk '{print $6}' <<< "${line}")"
618
+ [[ "${usage}" =~ ^[0-9]+$ ]] || continue
619
+
620
+ if (( usage < DISK_THRESHOLD_PERCENT )); then
621
+ continue
622
+ fi
623
+
624
+ over_limit=1
625
+ if [[ "${AUTO_REMEDIATE}" -eq 1 ]]; then
626
+ cleanup_disk_space
627
+ usage="$(df -P "${mountpoint}" | awk 'NR==2 {gsub("%","",$5); print $5}')"
628
+ fi
629
+
630
+ if [[ "${usage}" =~ ^[0-9]+$ ]] && (( usage < DISK_THRESHOLD_PERCENT )); then
631
+ action "Reduced disk usage on ${mountpoint} to ${usage}%."
632
+ else
633
+ issue "Disk usage on ${mountpoint} is ${usage}% (threshold ${DISK_THRESHOLD_PERCENT}%)."
634
+ fi
635
+ done < <(df -P -x tmpfs -x devtmpfs 2>/dev/null | awk 'NR>1')
636
+
637
+ if [[ "${over_limit}" -eq 0 ]]; then
638
+ log "Disk usage is below ${DISK_THRESHOLD_PERCENT}% on persistent filesystems."
639
+ fi
640
+ }
641
+
642
+ failed_login_count() {
643
+ local count=0
644
+
645
+ if command_exists journalctl; then
646
+ count="$(journalctl --since '-24 hours' --no-pager 2>/dev/null | grep -Eci 'Failed password|authentication failure|Invalid user|Failed publickey')"
647
+ echo "${count}"
648
+ return 0
649
+ fi
650
+
651
+ if command_exists lastb; then
652
+ count="$(lastb -s -24hours 2>/dev/null | awk 'NF > 0 && $1 != "btmp" { total++ } END { print total + 0 }')"
653
+ echo "${count}"
654
+ return 0
655
+ fi
656
+
657
+ if [[ -f /var/log/auth.log ]]; then
658
+ count="$(find /var/log -maxdepth 1 -name 'auth.log*' -mtime -1 -print0 2>/dev/null | xargs -0 zgrep -Ehi 'Failed password|authentication failure|Invalid user|Failed publickey' 2>/dev/null | wc -l | tr -d ' ')"
659
+ echo "${count:-0}"
660
+ return 0
661
+ fi
662
+
663
+ echo "0"
664
+ }
665
+
666
+ check_failed_logins() {
667
+ local count
668
+ count="$(failed_login_count)"
669
+ log "Failed login attempts in the last 24 hours: ${count}."
670
+
671
+ if [[ "${count}" =~ ^[0-9]+$ ]] && (( count > MAX_FAILED_LOGIN_ATTEMPTS )); then
672
+ issue "Failed login attempts exceed the threshold (${count} > ${MAX_FAILED_LOGIN_ATTEMPTS})."
673
+ fi
674
+ }
675
+
676
+ check_virustotal() {
677
+ local helper
678
+ local item
679
+ local any_item=0
680
+
681
+ if [[ "${VIRUSTOTAL_ENABLED}" -ne 1 ]]; then
682
+ log "VirusTotal checks are disabled."
683
+ return 0
684
+ fi
685
+
686
+ helper="${SCRIPT_DIR}/openclaw_virustotal_check.sh"
687
+ if [[ ! -x "${helper}" ]]; then
688
+ issue "VirusTotal helper script is missing or not executable: ${helper}"
689
+ return 0
690
+ fi
691
+
692
+ for item in ${VIRUSTOTAL_URLS}; do
693
+ any_item=1
694
+ if VIRUSTOTAL_ALLOW_UPLOADS="${VIRUSTOTAL_ALLOW_UPLOADS}" \
695
+ "${helper}" --url "${item}"; then
696
+ log "VirusTotal browser review prepared for URL: ${item}"
697
+ else
698
+ issue "VirusTotal browser workflow failed for URL: ${item}"
699
+ fi
700
+ done
701
+
702
+ for item in ${VIRUSTOTAL_FILES}; do
703
+ any_item=1
704
+ if VIRUSTOTAL_ALLOW_UPLOADS="${VIRUSTOTAL_ALLOW_UPLOADS}" \
705
+ "${helper}" --file "${item}"; then
706
+ log "VirusTotal browser review prepared for file: ${item}"
707
+ else
708
+ issue "VirusTotal browser workflow failed for file: ${item}"
709
+ fi
710
+ done
711
+
712
+ if [[ "${any_item}" -eq 0 ]]; then
713
+ warn "VirusTotal is enabled but no VIRUSTOTAL_URLS or VIRUSTOTAL_FILES were configured."
714
+ fi
715
+ }
716
+
717
+ main() {
718
+ log "Starting OpenClaw security audit."
719
+ [[ -n "${CONFIG_FILE}" ]] && log "Using configuration file: ${CONFIG_FILE}"
720
+
721
+ check_firewall
722
+ check_fail2ban
723
+ check_ssh
724
+ check_ports
725
+ check_docker
726
+ check_disk
727
+ check_failed_logins
728
+ check_auto_security_updates
729
+ check_virustotal
730
+
731
+ if (( ${#ISSUES[@]} == 0 )); then
732
+ printf 'audit de sécurité réussi\n'
733
+ exit 0
734
+ fi
735
+
736
+ printf '\nSecurity audit found %d issue(s).\n' "${#ISSUES[@]}"
737
+ printf '%s\n' "${ISSUES[@]}" | sed 's/^/- /'
738
+
739
+ if (( ${#ACTIONS[@]} > 0 )); then
740
+ printf '\nRemediation actions applied:\n'
741
+ printf '%s\n' "${ACTIONS[@]}" | sed 's/^/- /'
742
+ fi
743
+
744
+ if (( ${#WARNINGS[@]} > 0 )); then
745
+ printf '\nWarnings:\n'
746
+ printf '%s\n' "${WARNINGS[@]}" | sed 's/^/- /'
747
+ fi
748
+
749
+ exit 1
750
+ }
751
+
752
+ main "$@"
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ VIRUSTOTAL_ALLOW_UPLOADS="${VIRUSTOTAL_ALLOW_UPLOADS:-0}"
6
+
7
+ usage() {
8
+ cat <<'EOF'
9
+ Usage:
10
+ openclaw_virustotal_check.sh --url <url>
11
+ openclaw_virustotal_check.sh --file <path>
12
+
13
+ This helper is API-free. It prepares public VirusTotal review steps for the
14
+ OpenClaw browser tool.
15
+ EOF
16
+ }
17
+
18
+ fail() {
19
+ printf '[FAIL] %s\n' "$*" >&2
20
+ exit 1
21
+ }
22
+
23
+ info() {
24
+ printf '[INFO] %s\n' "$*"
25
+ }
26
+
27
+ warn() {
28
+ printf '[WARN] %s\n' "$*"
29
+ }
30
+
31
+ require_file() {
32
+ [[ -f "$1" ]] || fail "File not found: $1"
33
+ }
34
+
35
+ sha256_file() {
36
+ if command -v sha256sum >/dev/null 2>&1; then
37
+ sha256sum "$1" | awk '{print $1}'
38
+ return 0
39
+ fi
40
+ if command -v shasum >/dev/null 2>&1; then
41
+ shasum -a 256 "$1" | awk '{print $1}'
42
+ return 0
43
+ fi
44
+ fail "No SHA-256 tool found. Install sha256sum or shasum."
45
+ }
46
+
47
+ check_url() {
48
+ local url="$1"
49
+
50
+ [[ -n "${url}" ]] || fail "URL must not be empty."
51
+
52
+ info "VirusTotal API-free URL workflow prepared."
53
+ printf 'VT_BROWSER_URL=%s\n' "https://www.virustotal.com/gui/home/url"
54
+ printf 'VT_SUBMIT_URL=%s\n' "${url}"
55
+ printf 'OPENCLAW_BROWSER_COMMAND=%s\n' "browser.navigate https://www.virustotal.com/gui/home/url"
56
+ printf 'OPENCLAW_BROWSER_NOTE=%s\n' "Use browser.snapshot and browser.act to submit the URL and inspect detections."
57
+ }
58
+
59
+ check_file() {
60
+ local file_path="$1"
61
+ local sha256
62
+ local report_url
63
+
64
+ require_file "${file_path}"
65
+ sha256="$(sha256_file "${file_path}")"
66
+ report_url="https://www.virustotal.com/gui/file/${sha256}/detection"
67
+
68
+ info "VirusTotal API-free file workflow prepared."
69
+ printf 'VT_FILE_SHA256=%s\n' "${sha256}"
70
+ printf 'VT_GUI_REPORT=%s\n' "${report_url}"
71
+ printf 'VT_BROWSER_UPLOAD=%s\n' "https://www.virustotal.com/gui/home/upload"
72
+ printf 'OPENCLAW_BROWSER_NOTE=%s\n' "Open the report URL first. If no report exists and uploads are approved, use browser.upload on the upload page."
73
+
74
+ if [[ "${VIRUSTOTAL_ALLOW_UPLOADS}" -eq 1 ]]; then
75
+ printf 'VT_UPLOAD_ALLOWED=%s\n' "1"
76
+ else
77
+ printf 'VT_UPLOAD_ALLOWED=%s\n' "0"
78
+ warn "Website upload is disabled unless the user explicitly approves it."
79
+ fi
80
+ printf 'USER_DECISION_REQUIRED=%s\n' "If VirusTotal flags this file, ask the user whether to keep or remove it."
81
+ }
82
+
83
+ main() {
84
+ case "${1:-}" in
85
+ --url)
86
+ [[ $# -eq 2 ]] || { usage; exit 1; }
87
+ check_url "$2"
88
+ ;;
89
+ --file)
90
+ [[ $# -eq 2 ]] || { usage; exit 1; }
91
+ check_file "$2"
92
+ ;;
93
+ *)
94
+ usage
95
+ exit 1
96
+ ;;
97
+ esac
98
+ }
99
+
100
+ main "$@"