hackmyagent 0.11.4 → 0.11.6
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/README.md +59 -13
- package/dist/cli.js +212 -43
- package/dist/cli.js.map +1 -1
- package/dist/hardening/index.d.ts +1 -0
- package/dist/hardening/index.d.ts.map +1 -1
- package/dist/hardening/index.js +4 -1
- package/dist/hardening/index.js.map +1 -1
- package/dist/hardening/nemoclaw-scanner.d.ts +46 -0
- package/dist/hardening/nemoclaw-scanner.d.ts.map +1 -0
- package/dist/hardening/nemoclaw-scanner.js +1061 -0
- package/dist/hardening/nemoclaw-scanner.js.map +1 -0
- package/dist/hardening/scanner.d.ts +7 -0
- package/dist/hardening/scanner.d.ts.map +1 -1
- package/dist/hardening/scanner.js +598 -0
- package/dist/hardening/scanner.js.map +1 -1
- package/dist/hardening/taxonomy.d.ts.map +1 -1
- package/dist/hardening/taxonomy.js +40 -0
- package/dist/hardening/taxonomy.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- package/dist/index.js.map +1 -1
- package/dist/telemetry/contribute.d.ts +58 -49
- package/dist/telemetry/contribute.d.ts.map +1 -1
- package/dist/telemetry/contribute.js +187 -127
- package/dist/telemetry/contribute.js.map +1 -1
- package/dist/telemetry/index.d.ts +2 -2
- package/dist/telemetry/index.d.ts.map +1 -1
- package/dist/telemetry/index.js +8 -2
- package/dist/telemetry/index.js.map +1 -1
- package/dist/telemetry/opt-in.d.ts +22 -13
- package/dist/telemetry/opt-in.d.ts.map +1 -1
- package/dist/telemetry/opt-in.js +93 -102
- package/dist/telemetry/opt-in.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1061 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* NemoClaw Hardening Scanner
|
|
4
|
+
*
|
|
5
|
+
* Scans a live NemoClaw installation for misconfigurations, exposed secrets,
|
|
6
|
+
* network exposure, skill/blueprint integrity issues, and privilege escalation risks.
|
|
7
|
+
*
|
|
8
|
+
* Check ID range: HMA-NMC-001 through HMA-NMC-052
|
|
9
|
+
*/
|
|
10
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
13
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
14
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
15
|
+
}
|
|
16
|
+
Object.defineProperty(o, k2, desc);
|
|
17
|
+
}) : (function(o, m, k, k2) {
|
|
18
|
+
if (k2 === undefined) k2 = k;
|
|
19
|
+
o[k2] = m[k];
|
|
20
|
+
}));
|
|
21
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
22
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
23
|
+
}) : function(o, v) {
|
|
24
|
+
o["default"] = v;
|
|
25
|
+
});
|
|
26
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
27
|
+
var ownKeys = function(o) {
|
|
28
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
29
|
+
var ar = [];
|
|
30
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
31
|
+
return ar;
|
|
32
|
+
};
|
|
33
|
+
return ownKeys(o);
|
|
34
|
+
};
|
|
35
|
+
return function (mod) {
|
|
36
|
+
if (mod && mod.__esModule) return mod;
|
|
37
|
+
var result = {};
|
|
38
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
39
|
+
__setModuleDefault(result, mod);
|
|
40
|
+
return result;
|
|
41
|
+
};
|
|
42
|
+
})();
|
|
43
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
|
+
exports.NemoClawScanner = exports.NEMOCLAW_CATEGORIES = void 0;
|
|
45
|
+
const child_process_1 = require("child_process");
|
|
46
|
+
const fs_1 = require("fs");
|
|
47
|
+
const path = __importStar(require("path"));
|
|
48
|
+
const os = __importStar(require("os"));
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Constants
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
exports.NEMOCLAW_CATEGORIES = [
|
|
53
|
+
'secrets',
|
|
54
|
+
'network',
|
|
55
|
+
'skills',
|
|
56
|
+
'process',
|
|
57
|
+
'openclaw-layer',
|
|
58
|
+
];
|
|
59
|
+
const HOME = os.homedir();
|
|
60
|
+
const NEMOCLAW_DIR = path.join(HOME, '.nemoclaw');
|
|
61
|
+
const OPENSHELL_DIR = path.join(HOME, '.openshell');
|
|
62
|
+
const OPENCLAW_DIR = path.join(HOME, '.openclaw');
|
|
63
|
+
const NVAPI_PATTERNS = [/nvapi-[a-zA-Z0-9_-]{20,}/g, /nv-[a-zA-Z0-9_-]{20,}/g];
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Helpers
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
function safeExec(cmd) {
|
|
68
|
+
try {
|
|
69
|
+
return (0, child_process_1.execSync)(cmd, {
|
|
70
|
+
encoding: 'utf-8',
|
|
71
|
+
timeout: 5000,
|
|
72
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
73
|
+
}).trim();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function isValidContainerName(name) {
|
|
80
|
+
return /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name);
|
|
81
|
+
}
|
|
82
|
+
function maskSecret(value) {
|
|
83
|
+
if (value.length <= 4)
|
|
84
|
+
return '***';
|
|
85
|
+
return value.substring(0, 4) + '***';
|
|
86
|
+
}
|
|
87
|
+
function isWorldReadable(filePath) {
|
|
88
|
+
try {
|
|
89
|
+
const stats = (0, fs_1.statSync)(filePath);
|
|
90
|
+
return (stats.mode & 0o044) !== 0;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function isWorldWritable(filePath) {
|
|
97
|
+
try {
|
|
98
|
+
const stats = (0, fs_1.statSync)(filePath);
|
|
99
|
+
return (stats.mode & 0o022) !== 0;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function getListenersOnPort(port) {
|
|
106
|
+
const output = safeExec(process.platform === 'darwin'
|
|
107
|
+
? `lsof -iTCP:${port} -sTCP:LISTEN -P -n 2>/dev/null`
|
|
108
|
+
: `ss -tlnp 'sport = :${port}' 2>/dev/null`);
|
|
109
|
+
return output ? output.split('\n').filter(Boolean) : [];
|
|
110
|
+
}
|
|
111
|
+
function isBoundToNonLoopback(listeners) {
|
|
112
|
+
for (const line of listeners) {
|
|
113
|
+
if (line.includes('*:') || line.includes('0.0.0.0') || line.includes(':::')) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
function safeReadFile(filePath) {
|
|
120
|
+
try {
|
|
121
|
+
return (0, fs_1.readFileSync)(filePath, 'utf-8');
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function listFilesRecursive(dir, maxDepth = 3, depth = 0) {
|
|
128
|
+
if (depth >= maxDepth)
|
|
129
|
+
return [];
|
|
130
|
+
const results = [];
|
|
131
|
+
try {
|
|
132
|
+
const entries = (0, fs_1.readdirSync)(dir, { withFileTypes: true });
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
const full = path.join(dir, entry.name);
|
|
135
|
+
if (entry.isFile()) {
|
|
136
|
+
results.push(full);
|
|
137
|
+
}
|
|
138
|
+
else if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
139
|
+
results.push(...listFilesRecursive(full, maxDepth, depth + 1));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// permission denied or missing — skip
|
|
145
|
+
}
|
|
146
|
+
return results;
|
|
147
|
+
}
|
|
148
|
+
function finding(checkId, name, description, category, severity, passed, message, extra) {
|
|
149
|
+
return {
|
|
150
|
+
checkId,
|
|
151
|
+
name,
|
|
152
|
+
description,
|
|
153
|
+
category,
|
|
154
|
+
severity,
|
|
155
|
+
passed,
|
|
156
|
+
message,
|
|
157
|
+
fixable: extra?.fixable ?? false,
|
|
158
|
+
fixed: extra?.fixed,
|
|
159
|
+
fixMessage: extra?.fixMessage,
|
|
160
|
+
wouldFix: extra?.wouldFix,
|
|
161
|
+
file: extra?.file,
|
|
162
|
+
line: extra?.line,
|
|
163
|
+
fix: extra?.fix,
|
|
164
|
+
attackClass: extra?.attackClass,
|
|
165
|
+
details: extra?.details,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Detection helpers
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
function detectInstallation() {
|
|
172
|
+
const hasBinary = safeExec('which nemoclaw') !== null;
|
|
173
|
+
const hasConfig = (0, fs_1.existsSync)(NEMOCLAW_DIR) || (0, fs_1.existsSync)(OPENSHELL_DIR) || (0, fs_1.existsSync)(OPENCLAW_DIR);
|
|
174
|
+
const hasDocker = safeExec('docker info') !== null;
|
|
175
|
+
const hasK3s = safeExec('which k3s') !== null || (0, fs_1.existsSync)('/etc/rancher/k3s');
|
|
176
|
+
return { hasBinary, hasConfig, hasDocker, hasK3s };
|
|
177
|
+
}
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// NemoClawScanner
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
class NemoClawScanner {
|
|
182
|
+
async scan(targetDir, options = {}) {
|
|
183
|
+
const install = detectInstallation();
|
|
184
|
+
if (!install.hasBinary && !install.hasConfig && !install.hasDocker) {
|
|
185
|
+
return [
|
|
186
|
+
finding('HMA-NMC-000', 'NemoClaw not detected', 'No NemoClaw binary, configuration directories, or Docker daemon found', 'secrets', 'low', true, 'NemoClaw does not appear to be installed on this system'),
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
const findings = [];
|
|
190
|
+
// --- 1. Secrets & Credential Exposure ---
|
|
191
|
+
findings.push(...this.checkSecrets(targetDir));
|
|
192
|
+
// --- 2. Network Exposure ---
|
|
193
|
+
findings.push(...this.checkNetwork(install));
|
|
194
|
+
// --- 3. Skills & Blueprint Integrity ---
|
|
195
|
+
findings.push(...this.checkSkills(targetDir));
|
|
196
|
+
// --- 4. Process & Privilege ---
|
|
197
|
+
findings.push(...this.checkProcess(install));
|
|
198
|
+
// --- 5. OpenClaw Layer ---
|
|
199
|
+
findings.push(...this.checkOpenClawLayer(install));
|
|
200
|
+
// --- 6. Internet Exposure (bonus) ---
|
|
201
|
+
findings.push(...this.checkInternetExposure());
|
|
202
|
+
return findings;
|
|
203
|
+
}
|
|
204
|
+
// =========================================================================
|
|
205
|
+
// 1. Secrets & Credential Exposure (HMA-NMC-001 — HMA-NMC-006)
|
|
206
|
+
// =========================================================================
|
|
207
|
+
checkSecrets(targetDir) {
|
|
208
|
+
const results = [];
|
|
209
|
+
// HMA-NMC-001: NVIDIA API key in plaintext config files
|
|
210
|
+
results.push(...this.checkNMC001(targetDir));
|
|
211
|
+
// HMA-NMC-002: API key in OpenShell gateway logs
|
|
212
|
+
results.push(...this.checkNMC002());
|
|
213
|
+
// HMA-NMC-003: API key leaked into Docker environment
|
|
214
|
+
results.push(...this.checkNMC003());
|
|
215
|
+
// HMA-NMC-004: API key in shell history
|
|
216
|
+
results.push(...this.checkNMC004());
|
|
217
|
+
// HMA-NMC-005: Blueprint cache world-readable
|
|
218
|
+
results.push(...this.checkNMC005());
|
|
219
|
+
// HMA-NMC-006: OpenClaw session files world-readable
|
|
220
|
+
results.push(...this.checkNMC006());
|
|
221
|
+
return results;
|
|
222
|
+
}
|
|
223
|
+
checkNMC001(targetDir) {
|
|
224
|
+
const results = [];
|
|
225
|
+
const configFiles = [];
|
|
226
|
+
// Gather candidate files
|
|
227
|
+
const nemoConfig = path.join(NEMOCLAW_DIR, 'config');
|
|
228
|
+
if ((0, fs_1.existsSync)(nemoConfig))
|
|
229
|
+
configFiles.push(nemoConfig);
|
|
230
|
+
// .env files in working dir
|
|
231
|
+
try {
|
|
232
|
+
const entries = (0, fs_1.readdirSync)(targetDir);
|
|
233
|
+
for (const e of entries) {
|
|
234
|
+
if (e === '.env' || e.startsWith('.env.')) {
|
|
235
|
+
configFiles.push(path.join(targetDir, e));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// skip
|
|
241
|
+
}
|
|
242
|
+
// Dotfiles in working dir
|
|
243
|
+
try {
|
|
244
|
+
const entries = (0, fs_1.readdirSync)(targetDir);
|
|
245
|
+
for (const e of entries) {
|
|
246
|
+
if (e.startsWith('.') && !e.startsWith('.env')) {
|
|
247
|
+
const full = path.join(targetDir, e);
|
|
248
|
+
try {
|
|
249
|
+
if ((0, fs_1.statSync)(full).isFile())
|
|
250
|
+
configFiles.push(full);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// skip
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
// skip
|
|
260
|
+
}
|
|
261
|
+
// Also scan ~/.nemoclaw/ recursively for any files
|
|
262
|
+
if ((0, fs_1.existsSync)(NEMOCLAW_DIR)) {
|
|
263
|
+
configFiles.push(...listFilesRecursive(NEMOCLAW_DIR, 2));
|
|
264
|
+
}
|
|
265
|
+
let foundAny = false;
|
|
266
|
+
for (const file of configFiles) {
|
|
267
|
+
const content = safeReadFile(file);
|
|
268
|
+
if (!content)
|
|
269
|
+
continue;
|
|
270
|
+
const lines = content.split('\n');
|
|
271
|
+
for (let i = 0; i < lines.length; i++) {
|
|
272
|
+
for (const pat of NVAPI_PATTERNS) {
|
|
273
|
+
pat.lastIndex = 0;
|
|
274
|
+
const match = pat.exec(lines[i]);
|
|
275
|
+
if (match) {
|
|
276
|
+
foundAny = true;
|
|
277
|
+
results.push(finding('HMA-NMC-001', 'NVIDIA API key in plaintext config', 'An NVIDIA API key was found in a plaintext configuration file', 'secrets', 'critical', false, `Found key ${maskSecret(match[0])} in ${file} at line ${i + 1}`, {
|
|
278
|
+
file,
|
|
279
|
+
line: i + 1,
|
|
280
|
+
fixable: true,
|
|
281
|
+
fix: 'Move the API key to an environment variable and reference it as $NVIDIA_API_KEY',
|
|
282
|
+
attackClass: 'CRED-HARVEST',
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (!foundAny) {
|
|
289
|
+
results.push(finding('HMA-NMC-001', 'NVIDIA API key in plaintext config', 'No NVIDIA API keys found in plaintext configuration files', 'secrets', 'critical', true, 'No plaintext NVIDIA API keys detected in config files'));
|
|
290
|
+
}
|
|
291
|
+
return results;
|
|
292
|
+
}
|
|
293
|
+
checkNMC002() {
|
|
294
|
+
const logsDir = path.join(OPENSHELL_DIR, 'logs');
|
|
295
|
+
if (!(0, fs_1.existsSync)(logsDir)) {
|
|
296
|
+
return [
|
|
297
|
+
finding('HMA-NMC-002', 'API key in OpenShell gateway logs', 'OpenShell logs directory not found', 'secrets', 'high', true, 'No OpenShell logs directory at ~/.openshell/logs/'),
|
|
298
|
+
];
|
|
299
|
+
}
|
|
300
|
+
const results = [];
|
|
301
|
+
const logFiles = listFilesRecursive(logsDir, 2);
|
|
302
|
+
let foundAny = false;
|
|
303
|
+
for (const file of logFiles) {
|
|
304
|
+
const content = safeReadFile(file);
|
|
305
|
+
if (!content)
|
|
306
|
+
continue;
|
|
307
|
+
const lines = content.split('\n');
|
|
308
|
+
for (let i = 0; i < lines.length; i++) {
|
|
309
|
+
for (const pat of NVAPI_PATTERNS) {
|
|
310
|
+
pat.lastIndex = 0;
|
|
311
|
+
const match = pat.exec(lines[i]);
|
|
312
|
+
if (match) {
|
|
313
|
+
foundAny = true;
|
|
314
|
+
results.push(finding('HMA-NMC-002', 'API key in OpenShell gateway logs', 'An API key was found in OpenShell gateway log files', 'secrets', 'high', false, `Found key ${maskSecret(match[0])} in ${file} at line ${i + 1}`, {
|
|
315
|
+
file,
|
|
316
|
+
line: i + 1,
|
|
317
|
+
fixable: true,
|
|
318
|
+
fix: 'Purge log files containing keys: rm ~/.openshell/logs/*.log && configure log redaction in gateway settings',
|
|
319
|
+
attackClass: 'CRED-HARVEST',
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (!foundAny) {
|
|
326
|
+
results.push(finding('HMA-NMC-002', 'API key in OpenShell gateway logs', 'No API keys found in OpenShell gateway logs', 'secrets', 'high', true, 'No API keys detected in OpenShell log files'));
|
|
327
|
+
}
|
|
328
|
+
return results;
|
|
329
|
+
}
|
|
330
|
+
checkNMC003() {
|
|
331
|
+
const containerNames = safeExec("docker ps --format '{{.Names}}' 2>/dev/null");
|
|
332
|
+
if (!containerNames) {
|
|
333
|
+
return [
|
|
334
|
+
finding('HMA-NMC-003', 'API key leaked into Docker environment', 'Docker not available or no running containers', 'secrets', 'high', true, 'Docker daemon not reachable or no running containers'),
|
|
335
|
+
];
|
|
336
|
+
}
|
|
337
|
+
const results = [];
|
|
338
|
+
const names = containerNames.split('\n').filter(n => n && isValidContainerName(n));
|
|
339
|
+
let foundAny = false;
|
|
340
|
+
for (const name of names) {
|
|
341
|
+
const inspectOutput = safeExec(`docker inspect ${name} 2>/dev/null`);
|
|
342
|
+
if (!inspectOutput)
|
|
343
|
+
continue;
|
|
344
|
+
try {
|
|
345
|
+
const containers = JSON.parse(inspectOutput);
|
|
346
|
+
for (const container of containers) {
|
|
347
|
+
const envVars = container?.Config?.Env ?? [];
|
|
348
|
+
for (const envVar of envVars) {
|
|
349
|
+
for (const pat of NVAPI_PATTERNS) {
|
|
350
|
+
pat.lastIndex = 0;
|
|
351
|
+
const match = pat.exec(envVar);
|
|
352
|
+
if (match) {
|
|
353
|
+
foundAny = true;
|
|
354
|
+
results.push(finding('HMA-NMC-003', 'API key leaked into Docker environment', 'An NVIDIA API key was found in a Docker container environment variable', 'secrets', 'high', false, `Container "${name}" has key ${maskSecret(match[0])} in env`, {
|
|
355
|
+
fixable: false,
|
|
356
|
+
fix: 'Use Docker secrets or a secrets manager instead of environment variables. Recreate the container without the key in env.',
|
|
357
|
+
attackClass: 'CRED-HARVEST',
|
|
358
|
+
details: { container: name },
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
// malformed JSON — skip
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (!foundAny) {
|
|
370
|
+
results.push(finding('HMA-NMC-003', 'API key leaked into Docker environment', 'No API keys found in Docker container environments', 'secrets', 'high', true, 'No NVIDIA API keys detected in running container environments'));
|
|
371
|
+
}
|
|
372
|
+
return results;
|
|
373
|
+
}
|
|
374
|
+
checkNMC004() {
|
|
375
|
+
const historyFiles = [
|
|
376
|
+
path.join(HOME, '.bash_history'),
|
|
377
|
+
path.join(HOME, '.zsh_history'),
|
|
378
|
+
path.join(HOME, '.fish_history'),
|
|
379
|
+
];
|
|
380
|
+
const results = [];
|
|
381
|
+
let foundAny = false;
|
|
382
|
+
for (const file of historyFiles) {
|
|
383
|
+
const content = safeReadFile(file);
|
|
384
|
+
if (!content)
|
|
385
|
+
continue;
|
|
386
|
+
const lines = content.split('\n');
|
|
387
|
+
for (let i = 0; i < lines.length; i++) {
|
|
388
|
+
for (const pat of NVAPI_PATTERNS) {
|
|
389
|
+
pat.lastIndex = 0;
|
|
390
|
+
const match = pat.exec(lines[i]);
|
|
391
|
+
if (match) {
|
|
392
|
+
foundAny = true;
|
|
393
|
+
results.push(finding('HMA-NMC-004', 'API key in shell history', 'An NVIDIA API key was found in shell command history', 'secrets', 'high', false, `Found key ${maskSecret(match[0])} in ${path.basename(file)} at line ${i + 1}`, {
|
|
394
|
+
file,
|
|
395
|
+
line: i + 1,
|
|
396
|
+
fixable: true,
|
|
397
|
+
fix: `Remove the line from ${file} or clear history with: history -c && rm ${file}`,
|
|
398
|
+
attackClass: 'CRED-HARVEST',
|
|
399
|
+
}));
|
|
400
|
+
break; // one per history file is enough
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (foundAny)
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (!foundAny) {
|
|
408
|
+
results.push(finding('HMA-NMC-004', 'API key in shell history', 'No API keys found in shell history files', 'secrets', 'high', true, 'No NVIDIA API keys detected in shell history'));
|
|
409
|
+
}
|
|
410
|
+
return results;
|
|
411
|
+
}
|
|
412
|
+
checkNMC005() {
|
|
413
|
+
const blueprintsDir = path.join(NEMOCLAW_DIR, 'blueprints');
|
|
414
|
+
if (!(0, fs_1.existsSync)(blueprintsDir)) {
|
|
415
|
+
return [
|
|
416
|
+
finding('HMA-NMC-005', 'Blueprint cache world-readable', 'Blueprints directory not found', 'secrets', 'high', true, 'No blueprints directory at ~/.nemoclaw/blueprints/'),
|
|
417
|
+
];
|
|
418
|
+
}
|
|
419
|
+
const files = listFilesRecursive(blueprintsDir, 3);
|
|
420
|
+
const worldReadable = files.filter(isWorldReadable);
|
|
421
|
+
if (worldReadable.length === 0) {
|
|
422
|
+
return [
|
|
423
|
+
finding('HMA-NMC-005', 'Blueprint cache world-readable', 'Blueprint cache files have restricted permissions', 'secrets', 'high', true, 'All blueprint cache files have appropriate permissions'),
|
|
424
|
+
];
|
|
425
|
+
}
|
|
426
|
+
return [
|
|
427
|
+
finding('HMA-NMC-005', 'Blueprint cache world-readable', 'Blueprint cache files are readable by other users on the system', 'secrets', 'high', false, `${worldReadable.length} blueprint file(s) are world-readable`, {
|
|
428
|
+
fixable: true,
|
|
429
|
+
fix: 'chmod 600 ~/.nemoclaw/blueprints/**/*',
|
|
430
|
+
attackClass: 'CRED-HARVEST',
|
|
431
|
+
details: {
|
|
432
|
+
files: worldReadable.slice(0, 10),
|
|
433
|
+
total: worldReadable.length,
|
|
434
|
+
},
|
|
435
|
+
}),
|
|
436
|
+
];
|
|
437
|
+
}
|
|
438
|
+
checkNMC006() {
|
|
439
|
+
const sessionsDir = path.join(OPENCLAW_DIR, 'sessions');
|
|
440
|
+
if (!(0, fs_1.existsSync)(sessionsDir)) {
|
|
441
|
+
return [
|
|
442
|
+
finding('HMA-NMC-006', 'OpenClaw session files world-readable', 'OpenClaw sessions directory not found', 'secrets', 'medium', true, 'No sessions directory at ~/.openclaw/sessions/'),
|
|
443
|
+
];
|
|
444
|
+
}
|
|
445
|
+
const files = listFilesRecursive(sessionsDir, 3);
|
|
446
|
+
const worldReadable = files.filter(isWorldReadable);
|
|
447
|
+
if (worldReadable.length === 0) {
|
|
448
|
+
return [
|
|
449
|
+
finding('HMA-NMC-006', 'OpenClaw session files world-readable', 'OpenClaw session files have restricted permissions', 'secrets', 'medium', true, 'All OpenClaw session files have appropriate permissions'),
|
|
450
|
+
];
|
|
451
|
+
}
|
|
452
|
+
return [
|
|
453
|
+
finding('HMA-NMC-006', 'OpenClaw session files world-readable', 'OpenClaw session files are readable by other users on the system', 'secrets', 'medium', false, `${worldReadable.length} session file(s) are world-readable`, {
|
|
454
|
+
fixable: true,
|
|
455
|
+
fix: 'chmod 600 ~/.openclaw/sessions/**/*',
|
|
456
|
+
attackClass: 'CRED-HARVEST',
|
|
457
|
+
details: {
|
|
458
|
+
files: worldReadable.slice(0, 10),
|
|
459
|
+
total: worldReadable.length,
|
|
460
|
+
},
|
|
461
|
+
}),
|
|
462
|
+
];
|
|
463
|
+
}
|
|
464
|
+
// =========================================================================
|
|
465
|
+
// 2. Network Exposure (HMA-NMC-010 — HMA-NMC-015)
|
|
466
|
+
// =========================================================================
|
|
467
|
+
checkNetwork(install) {
|
|
468
|
+
const results = [];
|
|
469
|
+
// HMA-NMC-010: OpenShell gateway bound to 0.0.0.0
|
|
470
|
+
results.push(this.checkPortBinding('HMA-NMC-010', 'OpenShell gateway bound to 0.0.0.0', 'OpenShell gateway is listening on all interfaces instead of localhost only', 18789, 'network', 'critical', 'Reconfigure OpenShell to bind to 127.0.0.1 only: set bind_address=127.0.0.1 in ~/.openshell/config'));
|
|
471
|
+
// HMA-NMC-011: k3s API server on non-loopback
|
|
472
|
+
if (install.hasK3s) {
|
|
473
|
+
results.push(this.checkPortBinding('HMA-NMC-011', 'k3s API server on non-loopback', 'k3s API server is listening on all interfaces', 6443, 'network', 'critical', 'Start k3s with --bind-address 127.0.0.1 to restrict API access to localhost'));
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
results.push(finding('HMA-NMC-011', 'k3s API server on non-loopback', 'k3s not detected on this system', 'network', 'critical', true, 'k3s is not installed — check skipped'));
|
|
477
|
+
}
|
|
478
|
+
// HMA-NMC-012: Docker socket world-accessible
|
|
479
|
+
results.push(this.checkNMC012());
|
|
480
|
+
// HMA-NMC-013: Sandbox egress policy set to allow-all
|
|
481
|
+
results.push(this.checkNMC013());
|
|
482
|
+
// HMA-NMC-014: Brev remote endpoint without auth
|
|
483
|
+
results.push(this.checkNMC014());
|
|
484
|
+
// HMA-NMC-015: Inference gateway on non-loopback
|
|
485
|
+
results.push(this.checkPortBinding('HMA-NMC-015', 'Inference gateway on non-loopback', 'Inference gateway is listening on all interfaces instead of localhost only', 18791, 'network', 'critical', 'Reconfigure inference gateway to bind to 127.0.0.1: set inference_bind=127.0.0.1 in ~/.nemoclaw/config'));
|
|
486
|
+
return results;
|
|
487
|
+
}
|
|
488
|
+
checkPortBinding(checkId, name, description, port, category, severity, fixInstruction) {
|
|
489
|
+
const listeners = getListenersOnPort(port);
|
|
490
|
+
if (listeners.length === 0) {
|
|
491
|
+
return finding(checkId, name, `No listener detected on port ${port}`, category, severity, true, `Port ${port} is not in use`);
|
|
492
|
+
}
|
|
493
|
+
if (isBoundToNonLoopback(listeners)) {
|
|
494
|
+
return finding(checkId, name, description, category, severity, false, `Port ${port} is bound to a non-loopback address (accessible from network)`, {
|
|
495
|
+
fixable: true,
|
|
496
|
+
fix: fixInstruction,
|
|
497
|
+
attackClass: 'NETWORK-EXPOSURE',
|
|
498
|
+
details: { port, listeners: listeners.slice(0, 5) },
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
return finding(checkId, name, `Port ${port} is bound to loopback only`, category, severity, true, `Port ${port} is correctly bound to localhost`);
|
|
502
|
+
}
|
|
503
|
+
checkNMC012() {
|
|
504
|
+
const socketPath = '/var/run/docker.sock';
|
|
505
|
+
if (!(0, fs_1.existsSync)(socketPath)) {
|
|
506
|
+
return finding('HMA-NMC-012', 'Docker socket world-accessible', 'Docker socket not found', 'network', 'high', true, 'Docker socket not present at /var/run/docker.sock');
|
|
507
|
+
}
|
|
508
|
+
try {
|
|
509
|
+
const stats = (0, fs_1.statSync)(socketPath);
|
|
510
|
+
const otherRW = stats.mode & 0o006;
|
|
511
|
+
if (otherRW !== 0) {
|
|
512
|
+
return finding('HMA-NMC-012', 'Docker socket world-accessible', 'Docker socket is accessible by any user on the system', 'network', 'high', false, 'Docker socket at /var/run/docker.sock is world-accessible', {
|
|
513
|
+
fixable: true,
|
|
514
|
+
fix: 'chmod 660 /var/run/docker.sock && chown root:docker /var/run/docker.sock',
|
|
515
|
+
attackClass: 'PRIV-ESCALATION',
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
// cannot stat — likely permission denied, which is fine
|
|
521
|
+
}
|
|
522
|
+
return finding('HMA-NMC-012', 'Docker socket world-accessible', 'Docker socket permissions are restricted', 'network', 'high', true, 'Docker socket has appropriate permissions');
|
|
523
|
+
}
|
|
524
|
+
checkNMC013() {
|
|
525
|
+
const policiesDir = path.join(NEMOCLAW_DIR, 'policies');
|
|
526
|
+
if (!(0, fs_1.existsSync)(policiesDir)) {
|
|
527
|
+
return finding('HMA-NMC-013', 'Sandbox egress policy set to allow-all', 'No policies directory found', 'network', 'high', true, 'No NemoClaw policies directory at ~/.nemoclaw/policies/ — using defaults');
|
|
528
|
+
}
|
|
529
|
+
const policyFiles = listFilesRecursive(policiesDir, 2);
|
|
530
|
+
for (const file of policyFiles) {
|
|
531
|
+
const content = safeReadFile(file);
|
|
532
|
+
if (!content)
|
|
533
|
+
continue;
|
|
534
|
+
if (/egress\s*:\s*allow[_-]?all/i.test(content) || /egress_policy\s*=\s*["']?allow/i.test(content)) {
|
|
535
|
+
return finding('HMA-NMC-013', 'Sandbox egress policy set to allow-all', 'Sandbox containers are allowed unrestricted outbound network access', 'network', 'high', false, `Egress policy is set to allow-all in ${file}`, {
|
|
536
|
+
file,
|
|
537
|
+
fixable: true,
|
|
538
|
+
fix: 'Set egress policy to deny-all with explicit allowlist: egress: deny-all in policy file',
|
|
539
|
+
attackClass: 'NETWORK-EXPOSURE',
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return finding('HMA-NMC-013', 'Sandbox egress policy set to allow-all', 'Sandbox egress policy is restricted', 'network', 'high', true, 'No allow-all egress policy detected');
|
|
544
|
+
}
|
|
545
|
+
checkNMC014() {
|
|
546
|
+
const configFile = path.join(NEMOCLAW_DIR, 'config');
|
|
547
|
+
const content = safeReadFile(configFile);
|
|
548
|
+
if (!content) {
|
|
549
|
+
return finding('HMA-NMC-014', 'Brev remote endpoint without auth', 'NemoClaw config file not found', 'network', 'high', true, 'No NemoClaw config at ~/.nemoclaw/config');
|
|
550
|
+
}
|
|
551
|
+
const hasBrevEndpoint = /brev[_-]?endpoint/i.test(content) || /brev[_-]?remote/i.test(content);
|
|
552
|
+
const hasBrevToken = /brev[_-]?token\s*[=:]\s*\S+/i.test(content);
|
|
553
|
+
if (hasBrevEndpoint && !hasBrevToken) {
|
|
554
|
+
return finding('HMA-NMC-014', 'Brev remote endpoint without auth', 'A Brev remote endpoint is configured without an authentication token', 'network', 'high', false, 'Brev remote endpoint configured but no brev_token found', {
|
|
555
|
+
file: configFile,
|
|
556
|
+
fixable: true,
|
|
557
|
+
fix: 'Add brev_token=<your-token> to ~/.nemoclaw/config or set BREV_TOKEN env var',
|
|
558
|
+
attackClass: 'AUTH-BYPASS',
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
return finding('HMA-NMC-014', 'Brev remote endpoint without auth', 'Brev endpoint configuration is secure or not present', 'network', 'high', true, hasBrevEndpoint
|
|
562
|
+
? 'Brev remote endpoint has authentication token configured'
|
|
563
|
+
: 'No Brev remote endpoint configured');
|
|
564
|
+
}
|
|
565
|
+
// =========================================================================
|
|
566
|
+
// 3. Skills & Blueprint Integrity (HMA-NMC-020 — HMA-NMC-024)
|
|
567
|
+
// =========================================================================
|
|
568
|
+
checkSkills(targetDir) {
|
|
569
|
+
const results = [];
|
|
570
|
+
// HMA-NMC-020: Skills not verified against registry
|
|
571
|
+
results.push(this.checkNMC020(targetDir));
|
|
572
|
+
// HMA-NMC-021: Blueprint not pinned to digest
|
|
573
|
+
results.push(this.checkNMC021());
|
|
574
|
+
// HMA-NMC-022: Skills directory world-writable
|
|
575
|
+
results.push(this.checkNMC022(targetDir));
|
|
576
|
+
// HMA-NMC-023: Heartbeat URLs using HTTP not HTTPS
|
|
577
|
+
results.push(this.checkNMC023());
|
|
578
|
+
// HMA-NMC-024: Skills without Ed25519 signature
|
|
579
|
+
results.push(...this.checkNMC024(targetDir));
|
|
580
|
+
return results;
|
|
581
|
+
}
|
|
582
|
+
checkNMC020(targetDir) {
|
|
583
|
+
const skillsDir = path.join(targetDir, '.agents', 'skills');
|
|
584
|
+
if (!(0, fs_1.existsSync)(skillsDir)) {
|
|
585
|
+
return finding('HMA-NMC-020', 'Skills not verified against registry', 'No skills directory found', 'skills', 'high', true, 'No .agents/skills/ directory in target');
|
|
586
|
+
}
|
|
587
|
+
const unverified = [];
|
|
588
|
+
try {
|
|
589
|
+
const entries = (0, fs_1.readdirSync)(skillsDir, { withFileTypes: true });
|
|
590
|
+
for (const entry of entries) {
|
|
591
|
+
if (!entry.isDirectory())
|
|
592
|
+
continue;
|
|
593
|
+
const manifestPath = path.join(skillsDir, entry.name, 'manifest.yaml');
|
|
594
|
+
const manifestPathJson = path.join(skillsDir, entry.name, 'manifest.json');
|
|
595
|
+
const manifestContent = safeReadFile(manifestPath) ?? safeReadFile(manifestPathJson);
|
|
596
|
+
if (!manifestContent || !/registry[_-]?verified\s*[=:]\s*true/i.test(manifestContent)) {
|
|
597
|
+
unverified.push(entry.name);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
// skip
|
|
603
|
+
}
|
|
604
|
+
if (unverified.length === 0) {
|
|
605
|
+
return finding('HMA-NMC-020', 'Skills not verified against registry', 'All skills are verified against the registry', 'skills', 'high', true, 'All skills in .agents/skills/ have registry_verified=true');
|
|
606
|
+
}
|
|
607
|
+
return finding('HMA-NMC-020', 'Skills not verified against registry', 'One or more skills are not verified against the trust registry', 'skills', 'high', false, `${unverified.length} skill(s) not registry-verified: ${unverified.join(', ')}`, {
|
|
608
|
+
fixable: false,
|
|
609
|
+
fix: 'Verify skills against the registry: nemoclaw skill verify --all',
|
|
610
|
+
attackClass: 'SUPPLY-CHAIN',
|
|
611
|
+
details: { unverified },
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
checkNMC021() {
|
|
615
|
+
const blueprintsDir = path.join(NEMOCLAW_DIR, 'blueprints');
|
|
616
|
+
if (!(0, fs_1.existsSync)(blueprintsDir)) {
|
|
617
|
+
return finding('HMA-NMC-021', 'Blueprint not pinned to digest', 'No blueprints directory found', 'skills', 'high', true, 'No blueprints directory at ~/.nemoclaw/blueprints/');
|
|
618
|
+
}
|
|
619
|
+
const unpinned = [];
|
|
620
|
+
try {
|
|
621
|
+
const entries = (0, fs_1.readdirSync)(blueprintsDir, { withFileTypes: true });
|
|
622
|
+
for (const entry of entries) {
|
|
623
|
+
if (!entry.isDirectory())
|
|
624
|
+
continue;
|
|
625
|
+
const bpFile = path.join(blueprintsDir, entry.name, 'blueprint.yaml');
|
|
626
|
+
const content = safeReadFile(bpFile);
|
|
627
|
+
if (!content) {
|
|
628
|
+
unpinned.push(entry.name);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
// Check for digest field with a non-empty value
|
|
632
|
+
if (!/digest\s*[=:]\s*\S+/i.test(content)) {
|
|
633
|
+
unpinned.push(entry.name);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
// skip
|
|
639
|
+
}
|
|
640
|
+
if (unpinned.length === 0) {
|
|
641
|
+
return finding('HMA-NMC-021', 'Blueprint not pinned to digest', 'All blueprints are pinned to a content digest', 'skills', 'high', true, 'All blueprints have a digest pin');
|
|
642
|
+
}
|
|
643
|
+
return finding('HMA-NMC-021', 'Blueprint not pinned to digest', 'One or more blueprints are not pinned to a content digest, allowing supply-chain substitution', 'skills', 'high', false, `${unpinned.length} blueprint(s) without digest pin: ${unpinned.join(', ')}`, {
|
|
644
|
+
fixable: false,
|
|
645
|
+
fix: 'Pin blueprints to their content digest: nemoclaw blueprint pin --all',
|
|
646
|
+
attackClass: 'SUPPLY-CHAIN',
|
|
647
|
+
details: { unpinned },
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
checkNMC022(targetDir) {
|
|
651
|
+
const skillsDir = path.join(targetDir, '.agents', 'skills');
|
|
652
|
+
if (!(0, fs_1.existsSync)(skillsDir)) {
|
|
653
|
+
return finding('HMA-NMC-022', 'Skills directory world-writable', 'No skills directory found', 'skills', 'critical', true, 'No .agents/skills/ directory in target');
|
|
654
|
+
}
|
|
655
|
+
if (isWorldWritable(skillsDir)) {
|
|
656
|
+
return finding('HMA-NMC-022', 'Skills directory world-writable', 'The skills directory is writable by any user, allowing skill injection', 'skills', 'critical', false, '.agents/skills/ is world-writable', {
|
|
657
|
+
file: skillsDir,
|
|
658
|
+
fixable: true,
|
|
659
|
+
fix: 'chmod 755 .agents/skills/',
|
|
660
|
+
attackClass: 'SUPPLY-CHAIN',
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
return finding('HMA-NMC-022', 'Skills directory world-writable', 'Skills directory has appropriate write permissions', 'skills', 'critical', true, '.agents/skills/ is not world-writable');
|
|
664
|
+
}
|
|
665
|
+
checkNMC023() {
|
|
666
|
+
const configDirs = [OPENCLAW_DIR, NEMOCLAW_DIR, OPENSHELL_DIR];
|
|
667
|
+
const httpHeartbeats = [];
|
|
668
|
+
for (const dir of configDirs) {
|
|
669
|
+
if (!(0, fs_1.existsSync)(dir))
|
|
670
|
+
continue;
|
|
671
|
+
const files = listFilesRecursive(dir, 3);
|
|
672
|
+
for (const file of files) {
|
|
673
|
+
const content = safeReadFile(file);
|
|
674
|
+
if (!content)
|
|
675
|
+
continue;
|
|
676
|
+
const matches = content.match(/heartbeat[_-]?url\s*[=:]\s*http:\/\/[^\s"']+/gi);
|
|
677
|
+
if (matches) {
|
|
678
|
+
for (const m of matches) {
|
|
679
|
+
const url = m.replace(/heartbeat[_-]?url\s*[=:]\s*/i, '').trim();
|
|
680
|
+
httpHeartbeats.push({ file, url });
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (httpHeartbeats.length === 0) {
|
|
686
|
+
return finding('HMA-NMC-023', 'Heartbeat URLs using HTTP not HTTPS', 'No insecure heartbeat URLs found', 'skills', 'medium', true, 'No HTTP heartbeat URLs detected in configuration');
|
|
687
|
+
}
|
|
688
|
+
return finding('HMA-NMC-023', 'Heartbeat URLs using HTTP not HTTPS', 'Heartbeat endpoints are configured with HTTP instead of HTTPS, allowing interception', 'skills', 'medium', false, `${httpHeartbeats.length} heartbeat URL(s) using HTTP instead of HTTPS`, {
|
|
689
|
+
fixable: true,
|
|
690
|
+
fix: 'Change heartbeat URLs to use https:// in configuration files',
|
|
691
|
+
attackClass: 'NETWORK-EXPOSURE',
|
|
692
|
+
details: { urls: httpHeartbeats.slice(0, 10) },
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
checkNMC024(targetDir) {
|
|
696
|
+
const skillsDir = path.join(targetDir, '.agents', 'skills');
|
|
697
|
+
if (!(0, fs_1.existsSync)(skillsDir)) {
|
|
698
|
+
return [
|
|
699
|
+
finding('HMA-NMC-024', 'Skills without Ed25519 signature', 'No skills directory found', 'skills', 'high', true, 'No .agents/skills/ directory in target'),
|
|
700
|
+
];
|
|
701
|
+
}
|
|
702
|
+
const unsigned = [];
|
|
703
|
+
try {
|
|
704
|
+
const entries = (0, fs_1.readdirSync)(skillsDir, { withFileTypes: true });
|
|
705
|
+
for (const entry of entries) {
|
|
706
|
+
if (!entry.isDirectory())
|
|
707
|
+
continue;
|
|
708
|
+
const skillDir = path.join(skillsDir, entry.name);
|
|
709
|
+
// Look for .sig files
|
|
710
|
+
try {
|
|
711
|
+
const skillFiles = (0, fs_1.readdirSync)(skillDir);
|
|
712
|
+
const hasSig = skillFiles.some((f) => f.endsWith('.sig') || f.endsWith('.ed25519'));
|
|
713
|
+
if (!hasSig) {
|
|
714
|
+
unsigned.push(entry.name);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
unsigned.push(entry.name);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
// skip
|
|
724
|
+
}
|
|
725
|
+
if (unsigned.length === 0) {
|
|
726
|
+
return [
|
|
727
|
+
finding('HMA-NMC-024', 'Skills without Ed25519 signature', 'All skills have Ed25519 signatures', 'skills', 'high', true, 'All skills in .agents/skills/ have signature files'),
|
|
728
|
+
];
|
|
729
|
+
}
|
|
730
|
+
return [
|
|
731
|
+
finding('HMA-NMC-024', 'Skills without Ed25519 signature', 'One or more skills lack a cryptographic signature, making tampering undetectable', 'skills', 'high', false, `${unsigned.length} skill(s) without signature: ${unsigned.join(', ')}`, {
|
|
732
|
+
fixable: false,
|
|
733
|
+
fix: 'Sign skills with Ed25519: nemoclaw skill sign --key ~/.nemoclaw/signing.key --all',
|
|
734
|
+
attackClass: 'SUPPLY-CHAIN',
|
|
735
|
+
details: { unsigned },
|
|
736
|
+
}),
|
|
737
|
+
];
|
|
738
|
+
}
|
|
739
|
+
// =========================================================================
|
|
740
|
+
// 4. Process & Privilege (HMA-NMC-030 — HMA-NMC-034)
|
|
741
|
+
// =========================================================================
|
|
742
|
+
checkProcess(install) {
|
|
743
|
+
const results = [];
|
|
744
|
+
// HMA-NMC-030: NemoClaw running as root
|
|
745
|
+
results.push(this.checkNMC030());
|
|
746
|
+
// HMA-NMC-031: Docker sandbox with --privileged
|
|
747
|
+
results.push(this.checkNMC031(install));
|
|
748
|
+
// HMA-NMC-032: seccomp unconfined
|
|
749
|
+
results.push(this.checkNMC032(install));
|
|
750
|
+
// HMA-NMC-033: Landlock not supported by kernel
|
|
751
|
+
results.push(this.checkNMC033());
|
|
752
|
+
// HMA-NMC-034: k3s with --disable-network-policy
|
|
753
|
+
results.push(this.checkNMC034(install));
|
|
754
|
+
return results;
|
|
755
|
+
}
|
|
756
|
+
checkNMC030() {
|
|
757
|
+
const psOutput = safeExec('ps aux 2>/dev/null');
|
|
758
|
+
if (!psOutput) {
|
|
759
|
+
return finding('HMA-NMC-030', 'NemoClaw running as root', 'Unable to check NemoClaw process ownership', 'process', 'high', true, 'Could not inspect running processes');
|
|
760
|
+
}
|
|
761
|
+
const nemoLines = psOutput
|
|
762
|
+
.split('\n')
|
|
763
|
+
.filter((line) => /nemoclaw/i.test(line) && !/grep/i.test(line));
|
|
764
|
+
const rootProcesses = nemoLines.filter((line) => {
|
|
765
|
+
const parts = line.trim().split(/\s+/);
|
|
766
|
+
return parts[0] === 'root';
|
|
767
|
+
});
|
|
768
|
+
if (rootProcesses.length > 0) {
|
|
769
|
+
return finding('HMA-NMC-030', 'NemoClaw running as root', 'NemoClaw processes are running as the root user', 'process', 'high', false, `${rootProcesses.length} NemoClaw process(es) running as root`, {
|
|
770
|
+
fixable: false,
|
|
771
|
+
fix: 'Run NemoClaw as a non-root user: sudo -u nemoclaw nemoclaw start',
|
|
772
|
+
attackClass: 'PRIV-ESCALATION',
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
if (nemoLines.length === 0) {
|
|
776
|
+
return finding('HMA-NMC-030', 'NemoClaw running as root', 'No running NemoClaw processes found', 'process', 'high', true, 'No NemoClaw processes detected');
|
|
777
|
+
}
|
|
778
|
+
return finding('HMA-NMC-030', 'NemoClaw running as root', 'NemoClaw processes are running as a non-root user', 'process', 'high', true, 'NemoClaw is not running as root');
|
|
779
|
+
}
|
|
780
|
+
checkNMC031(install) {
|
|
781
|
+
if (!install.hasDocker) {
|
|
782
|
+
return finding('HMA-NMC-031', 'Docker sandbox with --privileged', 'Docker not available', 'process', 'critical', true, 'Docker daemon not reachable — check skipped');
|
|
783
|
+
}
|
|
784
|
+
const containerNames = safeExec("docker ps --format '{{.Names}}' 2>/dev/null");
|
|
785
|
+
if (!containerNames) {
|
|
786
|
+
return finding('HMA-NMC-031', 'Docker sandbox with --privileged', 'No running containers', 'process', 'critical', true, 'No running Docker containers');
|
|
787
|
+
}
|
|
788
|
+
const names = containerNames.split('\n').filter(n => n && isValidContainerName(n));
|
|
789
|
+
const privileged = [];
|
|
790
|
+
for (const name of names) {
|
|
791
|
+
const inspectOutput = safeExec(`docker inspect ${name} 2>/dev/null`);
|
|
792
|
+
if (!inspectOutput)
|
|
793
|
+
continue;
|
|
794
|
+
try {
|
|
795
|
+
const containers = JSON.parse(inspectOutput);
|
|
796
|
+
for (const container of containers) {
|
|
797
|
+
if (container?.HostConfig?.Privileged === true) {
|
|
798
|
+
privileged.push(name);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
catch {
|
|
803
|
+
// skip
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (privileged.length > 0) {
|
|
807
|
+
return finding('HMA-NMC-031', 'Docker sandbox with --privileged', 'One or more sandbox containers are running in privileged mode, defeating isolation', 'process', 'critical', false, `Privileged container(s): ${privileged.join(', ')}`, {
|
|
808
|
+
fixable: false,
|
|
809
|
+
fix: 'Recreate containers without --privileged. Use specific capabilities instead: --cap-add=SYS_PTRACE',
|
|
810
|
+
attackClass: 'SANDBOX-ESCAPE',
|
|
811
|
+
details: { privileged },
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
return finding('HMA-NMC-031', 'Docker sandbox with --privileged', 'No containers running in privileged mode', 'process', 'critical', true, 'No privileged containers detected');
|
|
815
|
+
}
|
|
816
|
+
checkNMC032(install) {
|
|
817
|
+
if (!install.hasDocker) {
|
|
818
|
+
return finding('HMA-NMC-032', 'seccomp unconfined', 'Docker not available', 'process', 'critical', true, 'Docker daemon not reachable — check skipped');
|
|
819
|
+
}
|
|
820
|
+
const containerNames = safeExec("docker ps --format '{{.Names}}' 2>/dev/null");
|
|
821
|
+
if (!containerNames) {
|
|
822
|
+
return finding('HMA-NMC-032', 'seccomp unconfined', 'No running containers', 'process', 'critical', true, 'No running Docker containers');
|
|
823
|
+
}
|
|
824
|
+
const names = containerNames.split('\n').filter(n => n && isValidContainerName(n));
|
|
825
|
+
const unconfined = [];
|
|
826
|
+
for (const name of names) {
|
|
827
|
+
const inspectOutput = safeExec(`docker inspect ${name} 2>/dev/null`);
|
|
828
|
+
if (!inspectOutput)
|
|
829
|
+
continue;
|
|
830
|
+
try {
|
|
831
|
+
const containers = JSON.parse(inspectOutput);
|
|
832
|
+
for (const container of containers) {
|
|
833
|
+
const securityOpts = container?.HostConfig?.SecurityOpt ?? [];
|
|
834
|
+
if (securityOpts.some((opt) => opt.includes('seccomp=unconfined'))) {
|
|
835
|
+
unconfined.push(name);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
catch {
|
|
840
|
+
// skip
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if (unconfined.length > 0) {
|
|
844
|
+
return finding('HMA-NMC-032', 'seccomp unconfined', 'One or more containers have seccomp disabled, allowing unrestricted system calls', 'process', 'critical', false, `Unconfined container(s): ${unconfined.join(', ')}`, {
|
|
845
|
+
fixable: false,
|
|
846
|
+
fix: 'Remove --security-opt seccomp=unconfined and use the default seccomp profile',
|
|
847
|
+
attackClass: 'SANDBOX-ESCAPE',
|
|
848
|
+
details: { unconfined },
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
return finding('HMA-NMC-032', 'seccomp unconfined', 'All containers have seccomp profiles applied', 'process', 'critical', true, 'No containers with seccomp=unconfined detected');
|
|
852
|
+
}
|
|
853
|
+
checkNMC033() {
|
|
854
|
+
if (process.platform !== 'linux') {
|
|
855
|
+
return finding('HMA-NMC-033', 'Landlock not supported by kernel', 'Landlock is a Linux-specific security feature', 'process', 'medium', true, `Current platform is ${process.platform} — Landlock check not applicable`);
|
|
856
|
+
}
|
|
857
|
+
const unameR = safeExec('uname -r');
|
|
858
|
+
if (!unameR) {
|
|
859
|
+
return finding('HMA-NMC-033', 'Landlock not supported by kernel', 'Unable to determine kernel version', 'process', 'medium', false, 'Could not determine kernel version', {
|
|
860
|
+
fix: 'Upgrade to Linux kernel >= 5.13 for Landlock filesystem sandboxing',
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
// Parse major.minor from kernel version string
|
|
864
|
+
const match = unameR.match(/^(\d+)\.(\d+)/);
|
|
865
|
+
if (!match) {
|
|
866
|
+
return finding('HMA-NMC-033', 'Landlock not supported by kernel', 'Could not parse kernel version', 'process', 'medium', false, `Kernel version ${unameR} could not be parsed`);
|
|
867
|
+
}
|
|
868
|
+
const major = parseInt(match[1], 10);
|
|
869
|
+
const minor = parseInt(match[2], 10);
|
|
870
|
+
if (major > 5 || (major === 5 && minor >= 13)) {
|
|
871
|
+
return finding('HMA-NMC-033', 'Landlock not supported by kernel', 'Kernel supports Landlock filesystem sandboxing', 'process', 'medium', true, `Kernel ${unameR} supports Landlock (>= 5.13)`);
|
|
872
|
+
}
|
|
873
|
+
return finding('HMA-NMC-033', 'Landlock not supported by kernel', 'The running kernel does not support Landlock, limiting filesystem sandboxing', 'process', 'medium', false, `Kernel ${unameR} does not support Landlock (requires >= 5.13)`, {
|
|
874
|
+
fixable: false,
|
|
875
|
+
fix: 'Upgrade to Linux kernel >= 5.13 for Landlock filesystem sandboxing',
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
checkNMC034(install) {
|
|
879
|
+
if (!install.hasK3s) {
|
|
880
|
+
return finding('HMA-NMC-034', 'k3s with --disable-network-policy', 'k3s not detected', 'process', 'high', true, 'k3s is not installed — check skipped');
|
|
881
|
+
}
|
|
882
|
+
const psOutput = safeExec('ps aux 2>/dev/null');
|
|
883
|
+
if (!psOutput) {
|
|
884
|
+
return finding('HMA-NMC-034', 'k3s with --disable-network-policy', 'Unable to check k3s process arguments', 'process', 'high', true, 'Could not inspect running processes');
|
|
885
|
+
}
|
|
886
|
+
const k3sLines = psOutput
|
|
887
|
+
.split('\n')
|
|
888
|
+
.filter((line) => /k3s\s+server/i.test(line));
|
|
889
|
+
const disabledPolicy = k3sLines.some((line) => /--disable[_-]network[_-]?policy/i.test(line));
|
|
890
|
+
if (disabledPolicy) {
|
|
891
|
+
return finding('HMA-NMC-034', 'k3s with --disable-network-policy', 'k3s is running with network policies disabled, allowing unrestricted pod-to-pod traffic', 'process', 'high', false, 'k3s server started with --disable-network-policy', {
|
|
892
|
+
fixable: false,
|
|
893
|
+
fix: 'Restart k3s without the --disable-network-policy flag and apply NetworkPolicy resources',
|
|
894
|
+
attackClass: 'NETWORK-EXPOSURE',
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
if (k3sLines.length === 0) {
|
|
898
|
+
return finding('HMA-NMC-034', 'k3s with --disable-network-policy', 'No running k3s server process found', 'process', 'high', true, 'k3s server process not currently running');
|
|
899
|
+
}
|
|
900
|
+
return finding('HMA-NMC-034', 'k3s with --disable-network-policy', 'k3s has network policies enabled', 'process', 'high', true, 'k3s server is running with network policies enabled');
|
|
901
|
+
}
|
|
902
|
+
// =========================================================================
|
|
903
|
+
// 5. OpenClaw Layer (HMA-NMC-040 — HMA-NMC-042)
|
|
904
|
+
// =========================================================================
|
|
905
|
+
checkOpenClawLayer(install) {
|
|
906
|
+
const results = [];
|
|
907
|
+
// HMA-NMC-040: Re-run OpenClaw checks inside sandbox
|
|
908
|
+
results.push(finding('HMA-NMC-040', 'Re-run OpenClaw checks inside sandbox', 'OpenClaw security checks should also be run inside the sandbox environment for full coverage', 'openclaw-layer', 'medium', true, 'Run hackmyagent scan inside the sandbox to check for OpenClaw-specific misconfigurations', {
|
|
909
|
+
fix: 'docker exec <sandbox-container> npx hackmyagent scan --ci /app',
|
|
910
|
+
}));
|
|
911
|
+
// HMA-NMC-041: OpenClaw heartbeat active inside sandbox
|
|
912
|
+
results.push(this.checkNMC041(install));
|
|
913
|
+
// HMA-NMC-042: OpenClaw sessions persisted outside sandbox
|
|
914
|
+
results.push(this.checkNMC042(install));
|
|
915
|
+
return results;
|
|
916
|
+
}
|
|
917
|
+
checkNMC041(install) {
|
|
918
|
+
if (!install.hasDocker) {
|
|
919
|
+
return finding('HMA-NMC-041', 'OpenClaw heartbeat active inside sandbox', 'Docker not available', 'openclaw-layer', 'high', true, 'Docker daemon not reachable — check skipped');
|
|
920
|
+
}
|
|
921
|
+
const containerNames = safeExec("docker ps --format '{{.Names}}' 2>/dev/null");
|
|
922
|
+
if (!containerNames) {
|
|
923
|
+
return finding('HMA-NMC-041', 'OpenClaw heartbeat active inside sandbox', 'No running containers', 'openclaw-layer', 'high', true, 'No running Docker containers');
|
|
924
|
+
}
|
|
925
|
+
const names = containerNames.split('\n').filter(n => n && isValidContainerName(n));
|
|
926
|
+
const noHeartbeat = [];
|
|
927
|
+
for (const name of names) {
|
|
928
|
+
// Check if container has OpenClaw heartbeat config
|
|
929
|
+
const result = safeExec(`docker exec ${name} test -f /root/.openclaw/heartbeat.yaml 2>/dev/null && echo exists`);
|
|
930
|
+
if (result === null || !result.includes('exists')) {
|
|
931
|
+
// Also check /home/*/.openclaw/
|
|
932
|
+
const altResult = safeExec(`docker exec ${name} sh -c 'ls /home/*/.openclaw/heartbeat.yaml 2>/dev/null' 2>/dev/null`);
|
|
933
|
+
if (!altResult) {
|
|
934
|
+
noHeartbeat.push(name);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
if (noHeartbeat.length === 0) {
|
|
939
|
+
return finding('HMA-NMC-041', 'OpenClaw heartbeat active inside sandbox', 'All sandbox containers have heartbeat configuration', 'openclaw-layer', 'high', true, 'OpenClaw heartbeat is configured in sandbox containers');
|
|
940
|
+
}
|
|
941
|
+
if (noHeartbeat.length === names.length) {
|
|
942
|
+
return finding('HMA-NMC-041', 'OpenClaw heartbeat active inside sandbox', 'No sandbox containers have OpenClaw heartbeat configuration', 'openclaw-layer', 'high', false, `No heartbeat config found in ${noHeartbeat.length} container(s)`, {
|
|
943
|
+
fixable: false,
|
|
944
|
+
fix: 'Mount heartbeat.yaml into sandbox: -v ~/.openclaw/heartbeat.yaml:/root/.openclaw/heartbeat.yaml:ro',
|
|
945
|
+
attackClass: 'MONITORING-GAP',
|
|
946
|
+
details: { containers: noHeartbeat },
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
return finding('HMA-NMC-041', 'OpenClaw heartbeat active inside sandbox', 'Some sandbox containers lack heartbeat configuration', 'openclaw-layer', 'high', false, `${noHeartbeat.length} container(s) without heartbeat: ${noHeartbeat.join(', ')}`, {
|
|
950
|
+
fixable: false,
|
|
951
|
+
fix: 'Mount heartbeat.yaml into sandbox: -v ~/.openclaw/heartbeat.yaml:/root/.openclaw/heartbeat.yaml:ro',
|
|
952
|
+
attackClass: 'MONITORING-GAP',
|
|
953
|
+
details: { containers: noHeartbeat },
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
checkNMC042(install) {
|
|
957
|
+
if (!install.hasDocker) {
|
|
958
|
+
return finding('HMA-NMC-042', 'OpenClaw sessions persisted outside sandbox', 'Docker not available', 'openclaw-layer', 'high', true, 'Docker daemon not reachable — check skipped');
|
|
959
|
+
}
|
|
960
|
+
const containerNames = safeExec("docker ps --format '{{.Names}}' 2>/dev/null");
|
|
961
|
+
if (!containerNames) {
|
|
962
|
+
return finding('HMA-NMC-042', 'OpenClaw sessions persisted outside sandbox', 'No running containers', 'openclaw-layer', 'high', true, 'No running Docker containers');
|
|
963
|
+
}
|
|
964
|
+
const names = containerNames.split('\n').filter(n => n && isValidContainerName(n));
|
|
965
|
+
const leaking = [];
|
|
966
|
+
for (const name of names) {
|
|
967
|
+
const inspectOutput = safeExec(`docker inspect ${name} 2>/dev/null`);
|
|
968
|
+
if (!inspectOutput)
|
|
969
|
+
continue;
|
|
970
|
+
try {
|
|
971
|
+
const containers = JSON.parse(inspectOutput);
|
|
972
|
+
for (const container of containers) {
|
|
973
|
+
const mounts = container?.Mounts ?? [];
|
|
974
|
+
for (const mount of mounts) {
|
|
975
|
+
if (mount.Source?.includes('.openclaw') &&
|
|
976
|
+
mount.Destination?.includes('.openclaw')) {
|
|
977
|
+
// Host .openclaw is bind-mounted into the container
|
|
978
|
+
leaking.push(name);
|
|
979
|
+
break;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
catch {
|
|
985
|
+
// skip
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
if (leaking.length === 0) {
|
|
989
|
+
return finding('HMA-NMC-042', 'OpenClaw sessions persisted outside sandbox', 'No containers have host OpenClaw directories bind-mounted', 'openclaw-layer', 'high', true, 'OpenClaw session data is not leaking from sandbox containers');
|
|
990
|
+
}
|
|
991
|
+
return finding('HMA-NMC-042', 'OpenClaw sessions persisted outside sandbox', 'Sandbox containers have host ~/.openclaw/ bind-mounted, allowing session data to persist outside the sandbox', 'openclaw-layer', 'high', false, `${leaking.length} container(s) with bind-mounted ~/.openclaw/: ${leaking.join(', ')}`, {
|
|
992
|
+
fixable: false,
|
|
993
|
+
fix: 'Use a named Docker volume instead of bind-mounting ~/.openclaw/. Remove -v ~/.openclaw:/root/.openclaw from container config.',
|
|
994
|
+
attackClass: 'DATA-LEAK',
|
|
995
|
+
details: { containers: leaking },
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
// =========================================================================
|
|
999
|
+
// 6. Internet Exposure — Bonus (HMA-NMC-050 — HMA-NMC-052)
|
|
1000
|
+
// =========================================================================
|
|
1001
|
+
checkInternetExposure() {
|
|
1002
|
+
const results = [];
|
|
1003
|
+
const checks = [
|
|
1004
|
+
{ id: 'HMA-NMC-050', name: 'Gateway port internet-exposed', port: 18789, service: 'OpenShell gateway' },
|
|
1005
|
+
{ id: 'HMA-NMC-051', name: 'k3s API internet-exposed', port: 6443, service: 'k3s API server' },
|
|
1006
|
+
{ id: 'HMA-NMC-052', name: 'Browser automation port exposed', port: 18791, service: 'browser automation endpoint' },
|
|
1007
|
+
];
|
|
1008
|
+
for (const check of checks) {
|
|
1009
|
+
const listeners = getListenersOnPort(check.port);
|
|
1010
|
+
if (listeners.length === 0) {
|
|
1011
|
+
results.push(finding(check.id, check.name, `${check.service} is not running on port ${check.port}`, 'network', 'critical', true, `Port ${check.port} is not in use`));
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
if (!isBoundToNonLoopback(listeners)) {
|
|
1015
|
+
results.push(finding(check.id, check.name, `${check.service} is bound to loopback only`, 'network', 'critical', true, `Port ${check.port} is bound to localhost — not internet-exposed`));
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
// Port is bound to non-loopback — check for firewall rules
|
|
1019
|
+
const hasFirewall = this.detectFirewallRule(check.port);
|
|
1020
|
+
if (hasFirewall) {
|
|
1021
|
+
results.push(finding(check.id, check.name, `${check.service} is on a non-loopback address but a firewall rule was detected`, 'network', 'critical', true, `Port ${check.port} is on non-loopback but firewall rule detected`));
|
|
1022
|
+
}
|
|
1023
|
+
else {
|
|
1024
|
+
results.push(finding(check.id, check.name, `${check.service} is bound to a non-loopback address with no detected firewall rule — potentially internet-exposed`, 'network', 'critical', false, `Port ${check.port} is potentially internet-exposed (no firewall rule detected)`, {
|
|
1025
|
+
fixable: true,
|
|
1026
|
+
fix: `Bind ${check.service} to 127.0.0.1 or add a firewall rule: sudo ufw deny ${check.port} (Linux) / sudo pfctl -e (macOS). Self-check: curl https://internetdb.shodan.io/$(curl -s ifconfig.me)`,
|
|
1027
|
+
attackClass: 'NETWORK-EXPOSURE',
|
|
1028
|
+
details: { port: check.port, listeners: listeners.slice(0, 5) },
|
|
1029
|
+
}));
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return results;
|
|
1033
|
+
}
|
|
1034
|
+
detectFirewallRule(port) {
|
|
1035
|
+
if (process.platform === 'darwin') {
|
|
1036
|
+
// macOS: check pf
|
|
1037
|
+
const pfStatus = safeExec('pfctl -s rules 2>/dev/null');
|
|
1038
|
+
if (pfStatus && pfStatus.includes(String(port)))
|
|
1039
|
+
return true;
|
|
1040
|
+
// Also check Application Firewall
|
|
1041
|
+
const fwStatus = safeExec('/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2>/dev/null');
|
|
1042
|
+
if (fwStatus && fwStatus.includes('enabled'))
|
|
1043
|
+
return true;
|
|
1044
|
+
}
|
|
1045
|
+
else {
|
|
1046
|
+
// Linux: check iptables / nftables / ufw
|
|
1047
|
+
const iptables = safeExec(`iptables -L -n 2>/dev/null`);
|
|
1048
|
+
if (iptables && iptables.includes(String(port)))
|
|
1049
|
+
return true;
|
|
1050
|
+
const ufw = safeExec('ufw status 2>/dev/null');
|
|
1051
|
+
if (ufw && ufw.includes(String(port)))
|
|
1052
|
+
return true;
|
|
1053
|
+
const nft = safeExec('nft list ruleset 2>/dev/null');
|
|
1054
|
+
if (nft && nft.includes(String(port)))
|
|
1055
|
+
return true;
|
|
1056
|
+
}
|
|
1057
|
+
return false;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
exports.NemoClawScanner = NemoClawScanner;
|
|
1061
|
+
//# sourceMappingURL=nemoclaw-scanner.js.map
|