hackmyagent 0.11.3 → 0.11.5

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 CHANGED
@@ -4,9 +4,9 @@
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/hackmyagent.svg)](https://www.npmjs.com/package/hackmyagent)
6
6
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
7
- [![Tests](https://img.shields.io/badge/tests-765%20passing-brightgreen)](https://github.com/opena2a-org/hackmyagent)
7
+ [![Tests](https://img.shields.io/badge/tests-1050%20passing-brightgreen)](https://github.com/opena2a-org/hackmyagent)
8
8
 
9
- **163 security checks for AI agents. Find what can go wrong before an attacker does.**
9
+ **173 security checks for AI agents. Find what can go wrong before an attacker does.**
10
10
 
11
11
  Security scanner and red-team toolkit for Claude Code, Cursor, VS Code, and any MCP server setup.
12
12
 
@@ -30,14 +30,19 @@ npx opena2a-cli review
30
30
 
31
31
  ## What It Finds
32
32
 
33
- **Attack testing:**
33
+ **Attack testing (115 payloads across 11 categories):**
34
34
  - **Prompt injection** -- tests whether agents follow injected instructions from untrusted input
35
35
  - **Data exfiltration** -- checks if agents can be tricked into leaking sensitive data to external endpoints
36
36
  - **Jailbreak and context manipulation** -- probes agent guardrails with adversarial prompts
37
37
  - **MCP exploitation** -- tests MCP servers for tool misuse, capability abuse, and unauthorized access
38
38
  - **Capability abuse** -- verifies agents can't exceed their intended permissions
39
+ - **Supply chain attacks** -- dependency confusion, tool shadowing, package impersonation
40
+ - **Memory weaponization** -- persistent instruction injection via agent memory systems
41
+ - **A2A protocol attacks** -- identity spoofing, capability escalation in multi-agent communication
42
+ - **Context window attacks** -- token flooding, attention manipulation, context poisoning
39
43
 
40
- **Static analysis:**
44
+ **Static analysis (173 checks across 34 categories):**
45
+ - **Unicode steganography** -- invisible codepoints (variation selectors, tag characters), zero-width characters (U+200B-200D), mid-file BOM injection, bidi override attacks (U+202A-202E), homoglyph confusables (Cyrillic/Greek/Fullwidth lookalikes), GlassWorm decoder patterns, and eval-on-invisible-payload detection. Scans JS, TS, Python, Markdown, YAML, JSON, and TOML files. ([real-world: os-info-checker-es6 npm attack, May 2025](https://thehackernews.com/2025/05/malicious-npm-package-leverages-unicode.html))
41
46
  - **Hardcoded credentials** -- API keys, tokens, and passwords in source or config files
42
47
  - **MCP server misconfigurations** -- open ports, root filesystem access, missing auth
43
48
  - **AI agent CVE detection** -- scans for CVE-2026-25253 (OpenClaw WebSocket RCE), CVE-2026-25157, CVE-2026-24763, and ClawHavoc IOCs
@@ -45,8 +50,10 @@ npx opena2a-cli review
45
50
  - **Governance gaps** -- missing SOUL.md, no capability policies, unsigned MCP servers
46
51
  - **Credential scope drift** -- Google Maps keys accessing Gemini, AWS S3 keys reaching Bedrock
47
52
  - **Supply chain risks** -- vulnerable dependencies, unsigned skills, tampered packages
53
+ - **Memory and RAG poisoning** -- persistent instruction injection, knowledge base contamination
54
+ - **Agent identity** -- missing cryptographic identity, capability claims without attestation
48
55
 
49
- 163 checks across 34 categories. 55+ attack payloads. No flags needed.
56
+ 173 checks across 34 categories. 115 attack payloads. No flags needed.
50
57
 
51
58
  ---
52
59
 
@@ -69,7 +76,7 @@ npm install --save-dev hackmyagent
69
76
  ```
70
77
 
71
78
  ┌──────────────────────────────────────────┐
72
- │ HackMyAgent v0.10.1 — Security Scanner │
79
+ │ HackMyAgent v0.11.4 — Security Scanner │
73
80
  │ Found: 3 critical · 5 high · 12 medium │
74
81
  │ │
75
82
  │ CRED-001 critical Hardcoded API key in .env │
@@ -88,7 +95,7 @@ npm install --save-dev hackmyagent
88
95
 
89
96
  Step-by-step guides for common workflows:
90
97
 
91
- - **[Scan my agent](docs/use-cases/scan-my-agent.md)** -- Run all 163 checks and auto-fix findings (5 min)
98
+ - **[Scan my agent](docs/use-cases/scan-my-agent.md)** -- Run all 173 checks and auto-fix findings (5 min)
92
99
  - **[Red-team MCP servers](docs/use-cases/red-team-mcp.md)** -- Test MCP servers with adversarial payloads (10 min)
93
100
  - **[Secure OpenClaw](docs/use-cases/openclaw-security.md)** -- OpenClaw-specific checks, CVE detection, ClawHavoc IOC scanning (10 min)
94
101
  - **[CI/CD pipeline](docs/use-cases/ci-pipeline.md)** -- GitHub Actions with JSON/SARIF output (5 min)
@@ -124,7 +131,7 @@ hackmyagent secure --publish # push results to OpenA2A Registry
124
131
 
125
132
 
126
133
  <details>
127
- <summary>All 30 security categories</summary>
134
+ <summary>All 34 security categories</summary>
128
135
 
129
136
  | Category | Checks | What it detects |
130
137
  |----------|--------|-----------------|
@@ -157,7 +164,11 @@ hackmyagent secure --publish # push results to OpenA2A Registry
157
164
  | CONFIG | 9 | Insecure default settings |
158
165
  | SUPPLY | 8 | Supply chain attack vectors |
159
166
  | SKILL | 12 | Malicious skill/tool detection |
160
- | HEARTBEAT | 6 | Heartbeat/cron abuse |
167
+ | HEARTBEAT | 7 | Heartbeat/cron abuse |
168
+ | UNICODE-STEGO | 5 | Invisible codepoints, zero-width chars, bidi attacks, homoglyphs, GlassWorm decoders |
169
+ | MEM | 5 | Memory poisoning, context injection |
170
+ | RAG | 4 | RAG/knowledge base poisoning |
171
+ | AIM | 3 | Agent identity verification |
161
172
 
162
173
  </details>
163
174
 
@@ -185,7 +196,7 @@ Use `--dry-run` to preview changes. Backups are created in `.hackmyagent-backup/
185
196
 
186
197
  ### `hackmyagent attack` -- Red Team
187
198
 
188
- Test your AI agent with 55 adversarial payloads across 5 attack categories.
199
+ Test your AI agent with 115 adversarial payloads across 11 attack categories.
189
200
 
190
201
  ```bash
191
202
  hackmyagent attack --local # local simulation
@@ -205,6 +216,12 @@ hackmyagent attack https://api.example.com --fail-on-vulnerable medium # CI gat
205
216
  | `data-exfiltration` | 11 | Extract sensitive data, system prompts, credentials |
206
217
  | `capability-abuse` | 10 | Misuse agent tools for unintended actions |
207
218
  | `context-manipulation` | 10 | Poison agent context or memory |
219
+ | `supply-chain` | 10 | Dependency confusion, package impersonation |
220
+ | `tool-shadow` | 10 | Tool shadowing, capability escalation |
221
+ | `mcp-exploitation` | 10 | MCP protocol abuse, tool injection |
222
+ | `memory-weaponization` | 10 | Persistent memory poisoning attacks |
223
+ | `a2a-attacks` | 10 | Agent-to-agent identity spoofing |
224
+ | `context-window` | 10 | Token flooding, attention manipulation |
208
225
 
209
226
  > Only test systems you own or have written authorization to test.
210
227
 
package/dist/cli.js CHANGED
@@ -41,6 +41,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
41
41
  const commander_1 = require("commander");
42
42
  const index_1 = require("./index");
43
43
  const resolve_mcp_1 = require("./resolve-mcp");
44
+ const nemoclaw_scanner_1 = require("./hardening/nemoclaw-scanner");
44
45
  const program = new commander_1.Command();
45
46
  // Write JSON to stdout synchronously with retry for pipe backpressure.
46
47
  // process.stdout.write() is async and gets truncated when process.exit()
@@ -1544,16 +1545,21 @@ function resolvePackageVersion(targetDir) {
1544
1545
  * Determines whether to contribute based on:
1545
1546
  * 1. --contribute / --no-contribute CLI flags (highest priority)
1546
1547
  * 2. ~/.opena2a/config.json contribute.enabled setting
1547
- * 3. Interactive opt-in prompt (first scan or scan #10)
1548
1548
  *
1549
- * If contributing, builds an anonymized payload and submits it
1550
- * asynchronously (non-blocking). Failures are logged as warnings.
1549
+ * If contributing, queues an anonymized event to ~/.opena2a/contribute-queue.json
1550
+ * (compatible with @opena2a/contribute format) and flushes when threshold reached.
1551
+ *
1552
+ * Also records the scan and shows a delayed consent tip after the 3rd scan
1553
+ * if the user hasn't opted in or dismissed.
1551
1554
  */
1552
- async function handleContribution(contributeFlag, targetDir, findings, registryUrl, format) {
1555
+ async function handleContribution(contributeFlag, targetDir, findings, durationMs, registryUrl, format) {
1553
1556
  try {
1554
- const { isContributeEnabled, shouldPromptContribute, showContributePrompt, incrementScanCount, buildContributionPayloadFromDir, submitContribution, } = await Promise.resolve().then(() => __importStar(require('./telemetry')));
1555
- // Always increment scan count
1556
- incrementScanCount();
1557
+ const { isContributeEnabled, recordScanAndMaybeShowTip, buildScanEvent, queueAndMaybeFlush, } = await Promise.resolve().then(() => __importStar(require('./telemetry')));
1558
+ // Record scan count and maybe show the delayed consent tip
1559
+ const tip = recordScanAndMaybeShowTip();
1560
+ if (tip && format === 'text' && process.stdout.isTTY) {
1561
+ process.stdout.write(tip + '\n');
1562
+ }
1557
1563
  // Determine whether to contribute
1558
1564
  let shouldContribute;
1559
1565
  if (contributeFlag === true) {
@@ -1566,35 +1572,19 @@ async function handleContribution(contributeFlag, targetDir, findings, registryU
1566
1572
  }
1567
1573
  else {
1568
1574
  // Check config
1569
- const configSetting = isContributeEnabled();
1570
- if (configSetting === true) {
1571
- shouldContribute = true;
1572
- }
1573
- else if (configSetting === false) {
1574
- shouldContribute = false;
1575
- }
1576
- else {
1577
- // Not configured -- prompt after 3 scans (interactive TTY only)
1578
- if (format === 'text' && process.stdout.isTTY && shouldPromptContribute()) {
1579
- shouldContribute = await showContributePrompt();
1580
- }
1581
- else {
1582
- shouldContribute = false;
1583
- }
1584
- }
1575
+ shouldContribute = isContributeEnabled() === true;
1585
1576
  }
1586
1577
  if (!shouldContribute)
1587
1578
  return;
1588
- // Build and submit contribution (non-blocking)
1579
+ // Build and queue contribution event (non-blocking, flushes at threshold)
1589
1580
  const packageName = resolvePackageName(targetDir);
1590
1581
  if (!packageName)
1591
1582
  return;
1592
- const payload = buildContributionPayloadFromDir(packageName, targetDir, findings);
1593
- const result = await submitContribution(payload, registryUrl);
1594
- if (result.success && format === 'text') {
1595
- console.log('Contributed anonymized scan summary to OpenA2A Registry (--no-contribute to opt out)');
1583
+ const event = buildScanEvent(packageName, targetDir, findings, durationMs);
1584
+ await queueAndMaybeFlush(event, registryUrl, format === 'text');
1585
+ if (format === 'text') {
1586
+ process.stdout.write('Queued anonymized scan summary for OpenA2A Registry (--no-contribute to opt out)\n');
1596
1587
  }
1597
- // Failures are silently ignored -- contribution is best-effort
1598
1588
  }
1599
1589
  catch {
1600
1590
  // Non-fatal: contribution failure must never crash the scan
@@ -1606,7 +1596,7 @@ async function handleContribution(contributeFlag, targetDir, findings, registryU
1606
1596
  * Converts SoulScanResult controls into SecurityFinding-like objects
1607
1597
  * for the contribution module, then delegates to handleContribution.
1608
1598
  */
1609
- async function handleSoulContribution(contributeFlag, targetDir, result, registryUrl, format) {
1599
+ async function handleSoulContribution(contributeFlag, targetDir, result, durationMs, registryUrl, format) {
1610
1600
  // Convert soul controls into SecurityFinding-shaped objects
1611
1601
  const findings = [];
1612
1602
  for (const domain of result.domains) {
@@ -1625,7 +1615,7 @@ async function handleSoulContribution(contributeFlag, targetDir, result, registr
1625
1615
  });
1626
1616
  }
1627
1617
  }
1628
- await handleContribution(contributeFlag, targetDir, findings, registryUrl, format);
1618
+ await handleContribution(contributeFlag, targetDir, findings, durationMs, registryUrl, format);
1629
1619
  }
1630
1620
  program
1631
1621
  .command('secure')
@@ -1686,9 +1676,18 @@ Examples:
1686
1676
  .option('--registry-key <key>', 'Registry API key (default: REGISTRY_API_KEY env)')
1687
1677
  .option('--contribute', 'Share anonymized scan findings with OpenA2A Registry (overrides config)')
1688
1678
  .option('--no-contribute', 'Do not share findings for this scan (overrides config)')
1679
+ .option('--ci', 'CI mode: suppress interactive prompts, exit non-zero on findings')
1689
1680
  .action(async (directory, options) => {
1690
1681
  try {
1691
1682
  const targetDir = directory.startsWith('/') ? directory : process.cwd() + '/' + directory;
1683
+ // CI mode: force non-interactive defaults
1684
+ if (options.ci) {
1685
+ if (!options.format && !options.json)
1686
+ options.format = 'text';
1687
+ // In CI, never prompt -- only contribute if explicitly --contribute
1688
+ if (options.contribute === undefined)
1689
+ options.contribute = false;
1690
+ }
1692
1691
  // Check if directory exists
1693
1692
  if (!require('fs').existsSync(targetDir)) {
1694
1693
  console.error(`Error: Directory '${targetDir}' does not exist.`);
@@ -1746,6 +1745,7 @@ Examples:
1746
1745
  }
1747
1746
  }
1748
1747
  const scanner = new index_1.HardeningScanner();
1748
+ const scanStartMs = Date.now();
1749
1749
  const result = await scanner.scan({
1750
1750
  targetDir,
1751
1751
  autoFix: options.fix ?? false,
@@ -1755,6 +1755,7 @@ Examples:
1755
1755
  cliName: CLI_PREFIX,
1756
1756
  onProgress,
1757
1757
  });
1758
+ const scanDurationMs = Date.now() - scanStartMs;
1758
1759
  // OASB-2 composite mode: infrastructure (50%) + governance (50%)
1759
1760
  if (isOasb2) {
1760
1761
  const infraResult = generateBenchmarkReport(result.allFindings || result.findings, level, options.category);
@@ -1890,7 +1891,7 @@ Examples:
1890
1891
  writeJsonStdout(jsonOutput);
1891
1892
  }
1892
1893
  // Community contribution (non-blocking, runs in JSON mode too)
1893
- await handleContribution(options.contribute, targetDir, result.findings, options.registryUrl, format);
1894
+ await handleContribution(options.contribute, targetDir, result.findings, scanDurationMs, options.registryUrl, format);
1894
1895
  const critHigh = result.findings.filter((f) => !f.passed && !f.fixed && (f.severity === 'critical' || f.severity === 'high'));
1895
1896
  if (critHigh.length > 0)
1896
1897
  process.exitCode = 1;
@@ -2119,12 +2120,15 @@ Examples:
2119
2120
  }
2120
2121
  }
2121
2122
  // Community contribution: share anonymized findings with OpenA2A Registry
2122
- await handleContribution(options.contribute, targetDir, result.findings, options.registryUrl, format);
2123
+ await handleContribution(options.contribute, targetDir, result.findings, scanDurationMs, options.registryUrl, format);
2123
2124
  // Star prompt (interactive TTY only, text format only)
2124
2125
  if (process.stdout.isTTY) {
2125
2126
  console.log(`${colors.cyan}Helpful?${RESET()} Star the project: https://github.com/opena2a-org/opena2a\n`);
2126
2127
  }
2127
- // Exit with non-zero if critical/high issues remain
2128
+ // Exit with non-zero if critical/high issues remain (or any issues in --ci mode)
2129
+ if (options.ci && issues.length > 0) {
2130
+ process.exit(1);
2131
+ }
2128
2132
  const criticalOrHigh = issues.filter((f) => f.severity === 'critical' || f.severity === 'high');
2129
2133
  if (criticalOrHigh.length > 0) {
2130
2134
  process.exit(1);
@@ -2365,6 +2369,180 @@ Examples:
2365
2369
  process.exit(1);
2366
2370
  }
2367
2371
  });
2372
+ // NemoClaw-specific helpers
2373
+ const NEMOCLAW_CHECK_CATEGORIES = nemoclaw_scanner_1.NEMOCLAW_CATEGORIES;
2374
+ function detectNemoClawDirectory(providedDir) {
2375
+ const os = require('os');
2376
+ const fs = require('fs');
2377
+ const path = require('path');
2378
+ if (providedDir && providedDir !== '') {
2379
+ return providedDir.startsWith('/') ? providedDir : path.join(process.cwd(), providedDir);
2380
+ }
2381
+ const homeDir = os.homedir();
2382
+ const candidates = [
2383
+ path.join(homeDir, '.nemoclaw'),
2384
+ path.join(homeDir, '.openshell'),
2385
+ path.join(homeDir, '.openclaw'),
2386
+ ];
2387
+ for (const candidate of candidates) {
2388
+ if (fs.existsSync(candidate)) {
2389
+ return candidate;
2390
+ }
2391
+ }
2392
+ return process.cwd();
2393
+ }
2394
+ function filterNemoClawFindings(findings) {
2395
+ return findings.filter((f) => {
2396
+ const checkId = f.checkId.toUpperCase();
2397
+ return checkId.startsWith('HMA-NMC-');
2398
+ });
2399
+ }
2400
+ function assessNemoClawRiskLevel(findings) {
2401
+ const criticalCount = findings.filter((f) => f.severity === 'critical').length;
2402
+ const highCount = findings.filter((f) => f.severity === 'high').length;
2403
+ const mediumCount = findings.filter((f) => f.severity === 'medium').length;
2404
+ if (criticalCount > 0) {
2405
+ return {
2406
+ level: 'Critical',
2407
+ color: colors.brightRed,
2408
+ description: `${criticalCount} critical finding(s) with recommended fixes available.`,
2409
+ };
2410
+ }
2411
+ if (highCount > 0) {
2412
+ return {
2413
+ level: 'High',
2414
+ color: colors.red,
2415
+ description: `${highCount} high-severity finding(s) detected. Fixes available below.`,
2416
+ };
2417
+ }
2418
+ if (mediumCount > 0) {
2419
+ return {
2420
+ level: 'Moderate',
2421
+ color: colors.yellow,
2422
+ description: 'Some findings detected. Review the recommendations below.',
2423
+ };
2424
+ }
2425
+ if (findings.length === 0) {
2426
+ return {
2427
+ level: 'None',
2428
+ color: colors.dim,
2429
+ description: `No NemoClaw installation detected. Run \`${CLI_PREFIX} secure\` for a full scan.`,
2430
+ };
2431
+ }
2432
+ return {
2433
+ level: 'Low',
2434
+ color: colors.green,
2435
+ description: 'No critical or high findings detected.',
2436
+ };
2437
+ }
2438
+ program
2439
+ .command('secure-nemoclaw')
2440
+ .description(`Security scan for NVIDIA NemoClaw installations
2441
+
2442
+ Performs focused security checks for NemoClaw sandbox deployments:
2443
+ - Secrets: NVIDIA API key exposure in configs, logs, Docker, shell history
2444
+ - Network: Gateway/k3s/inference port binding, Docker socket, egress policies
2445
+ - Skills: Blueprint integrity, skill verification, directory permissions
2446
+ - Process: Sandbox privileges, seccomp/Landlock enforcement, root execution
2447
+ - OpenClaw layer: Inherited misconfigs that survive NemoClaw sandboxing
2448
+
2449
+ Auto-detects ~/.nemoclaw, ~/.openshell, or ~/.openclaw directories.
2450
+ Exit code 1 if critical/high issues found.
2451
+
2452
+ Examples:
2453
+ $ hackmyagent secure-nemoclaw Scan auto-detected directory
2454
+ $ hackmyagent secure-nemoclaw ~/.nemoclaw Scan specific directory
2455
+ $ hackmyagent secure-nemoclaw --json JSON output for CI`)
2456
+ .argument('[directory]', 'Directory to scan (default: ~/.nemoclaw or ~/.openshell)', '')
2457
+ .option('--json', 'Output as JSON (for scripting/CI)')
2458
+ .option('-v, --verbose', 'Show all checks including passed ones')
2459
+ .action(async (directory, options) => {
2460
+ try {
2461
+ const targetDir = detectNemoClawDirectory(directory);
2462
+ if (!options.json) {
2463
+ console.log(`\nNemoClaw Security Report`);
2464
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
2465
+ console.log(`Scanning ${targetDir}...\n`);
2466
+ }
2467
+ const scanner = new nemoclaw_scanner_1.NemoClawScanner();
2468
+ const findings = await scanner.scan(targetDir, {});
2469
+ // Enrich with taxonomy
2470
+ const { enrichWithTaxonomy } = require('./hardening/taxonomy');
2471
+ enrichWithTaxonomy(findings);
2472
+ const issues = findings.filter((f) => !f.passed);
2473
+ const passedFindings = findings.filter((f) => f.passed);
2474
+ if (options.json) {
2475
+ const jsonOutput = {
2476
+ target: targetDir,
2477
+ riskLevel: assessNemoClawRiskLevel(issues).level,
2478
+ totalChecks: findings.length,
2479
+ issues: issues.length,
2480
+ passed: passedFindings.length,
2481
+ findings: findings,
2482
+ };
2483
+ writeJsonStdout(jsonOutput);
2484
+ return;
2485
+ }
2486
+ // Risk assessment
2487
+ const risk = assessNemoClawRiskLevel(issues);
2488
+ console.log(`Risk Level: ${risk.color}${risk.level}${RESET()}`);
2489
+ console.log(`${risk.description}\n`);
2490
+ // Summary stats
2491
+ console.log(`Checks: ${findings.length} total | ${issues.length} issues | ${passedFindings.length} passed\n`);
2492
+ // Show issues
2493
+ if (issues.length > 0) {
2494
+ console.log(`${colors.red}Findings:${RESET()}\n`);
2495
+ for (const finding of issues) {
2496
+ const display = SEVERITY_DISPLAY[finding.severity];
2497
+ const location = finding.file
2498
+ ? finding.line
2499
+ ? `${finding.file}:${finding.line}`
2500
+ : finding.file
2501
+ : '';
2502
+ const sevLabel = finding.severity.charAt(0).toUpperCase() + finding.severity.slice(1);
2503
+ console.log(`${display.color()}${display.symbol} [${finding.checkId}] ${sevLabel}${RESET()}`);
2504
+ console.log(` ${finding.description}`);
2505
+ if (location) {
2506
+ console.log(` File: ${location}`);
2507
+ }
2508
+ if (finding.fix) {
2509
+ console.log(` ${colors.cyan}Recommended fix:${RESET()} ${finding.fix}`);
2510
+ }
2511
+ console.log();
2512
+ }
2513
+ }
2514
+ else {
2515
+ console.log(`${colors.green}No NemoClaw-specific issues found.${RESET()}\n`);
2516
+ }
2517
+ // Show passed checks in verbose mode
2518
+ if (options.verbose && passedFindings.length > 0) {
2519
+ console.log(`${colors.green}Passed Checks:${RESET()}`);
2520
+ for (const finding of passedFindings) {
2521
+ console.log(` ${colors.green}[ok]${RESET()} [${finding.checkId}] ${finding.name}`);
2522
+ }
2523
+ console.log();
2524
+ }
2525
+ // Shodan self-check guidance
2526
+ if (issues.some((f) => f.category === 'network')) {
2527
+ console.log(`${colors.yellow}Internet Exposure Check:${RESET()}`);
2528
+ console.log(` Check if your instance is visible on Shodan:`);
2529
+ console.log(` https://www.shodan.io/host/<YOUR-IP>`);
2530
+ console.log(` Known NemoClaw dorks: port:18789, port:6443 ssl.cert.subject.cn:"k3s-serving"\n`);
2531
+ }
2532
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
2533
+ console.log(`Run '${CLI_PREFIX} secure-openclaw' for OpenClaw-specific checks.`);
2534
+ console.log(`Run '${CLI_PREFIX} secure' for a full security scan.\n`);
2535
+ // Exit with non-zero if critical/high issues remain
2536
+ const criticalOrHigh = issues.filter((f) => f.severity === 'critical' || f.severity === 'high');
2537
+ if (criticalOrHigh.length > 0) {
2538
+ process.exit(1);
2539
+ }
2540
+ }
2541
+ catch (error) {
2542
+ console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
2543
+ process.exit(1);
2544
+ }
2545
+ });
2368
2546
  program
2369
2547
  .command('scan')
2370
2548
  .description(`Scan external target for exposed MCP endpoints
@@ -2390,7 +2568,9 @@ Examples:
2390
2568
  .option('-v, --verbose', 'Show detailed finding information')
2391
2569
  .action(async (target, options) => {
2392
2570
  try {
2393
- console.log(`\nScanning ${target}...\n`);
2571
+ if (!options.json) {
2572
+ console.log(`\nScanning ${target}...\n`);
2573
+ }
2394
2574
  const scanner = new index_1.ExternalScanner();
2395
2575
  const customPorts = options.ports
2396
2576
  ? options.ports.split(',').map((p) => parseInt(p.trim(), 10))
@@ -4018,21 +4198,29 @@ Examples:
4018
4198
  .option('--registry-url <url>', 'Registry URL (default: REGISTRY_URL env)', process.env.REGISTRY_URL || 'https://api.oa2a.org')
4019
4199
  .option('--contribute', 'Share anonymized scan findings with OpenA2A Registry (overrides config)')
4020
4200
  .option('--no-contribute', 'Do not share findings for this scan (overrides config)')
4201
+ .option('--ci', 'CI mode: suppress interactive prompts, exit non-zero on findings')
4021
4202
  .action(async (directory, options) => {
4022
4203
  try {
4023
4204
  const targetDir = directory.startsWith('/') ? directory : process.cwd() + '/' + directory;
4205
+ // CI mode: force non-interactive defaults
4206
+ if (options.ci) {
4207
+ if (options.contribute === undefined)
4208
+ options.contribute = false;
4209
+ }
4024
4210
  if (!require('fs').existsSync(targetDir)) {
4025
4211
  process.stderr.write(`Error: Directory '${targetDir}' does not exist.\n`);
4026
4212
  process.exit(1);
4027
4213
  }
4028
4214
  const prefix = getCommandPrefix();
4029
4215
  const scanner = new index_1.SoulScanner();
4216
+ const soulScanStartMs = Date.now();
4030
4217
  const result = await scanner.scanSoul(targetDir, {
4031
4218
  verbose: options.verbose,
4032
4219
  tier: options.tier,
4033
4220
  profile: options.profile,
4034
4221
  deepAnalysis: options.deep,
4035
4222
  });
4223
+ const soulScanDurationMs = Date.now() - soulScanStartMs;
4036
4224
  // JSON output
4037
4225
  if (options.json) {
4038
4226
  // Run publish in JSON mode and include result in output
@@ -4070,6 +4258,7 @@ Examples:
4070
4258
  process.exit(1);
4071
4259
  }
4072
4260
  }
4261
+ await handleSoulContribution(options.contribute, targetDir, result, soulScanDurationMs, options.registryUrl, 'json');
4073
4262
  return;
4074
4263
  }
4075
4264
  // Text output
@@ -4183,7 +4372,14 @@ Examples:
4183
4372
  }
4184
4373
  // Community contribution: share anonymized findings with OpenA2A Registry
4185
4374
  const soulFormat = options.json ? 'json' : 'text';
4186
- await handleSoulContribution(options.contribute, targetDir, result, options.registryUrl, soulFormat);
4375
+ await handleSoulContribution(options.contribute, targetDir, result, soulScanDurationMs, options.registryUrl, soulFormat);
4376
+ // In CI mode, exit non-zero if any controls failed
4377
+ if (options.ci) {
4378
+ const failedControls = result.domains.flatMap(d => d.controls).filter(c => !c.passed);
4379
+ if (failedControls.length > 0) {
4380
+ process.exit(1);
4381
+ }
4382
+ }
4187
4383
  // Check fail threshold
4188
4384
  if (options.failBelow) {
4189
4385
  const threshold = parseInt(options.failBelow, 10);