ship-safe 9.2.4 → 9.3.1

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
@@ -98,7 +98,7 @@ $ ship-safe
98
98
  ███████╗██╗ ██╗██╗██████╗ ███████╗ █████╗ ███████╗███████╗
99
99
  ...
100
100
 
101
- v9.2.3 · DeepSeek · ~/my-project
101
+ v9.3.1 · DeepSeek · ~/my-project
102
102
 
103
103
  /scan to find issues · /agent to fix them · /help for more
104
104
 
@@ -61,7 +61,7 @@ const PATTERNS = [
61
61
  owasp: 'ASI-10',
62
62
  confidence: 'high',
63
63
  description: 'hermes-agent package version is not pinned — a malicious minor/patch release could modify agent behavior.',
64
- fix: 'Pin to exact version: "\"@nousresearch/hermes-agent\": \"1.2.3\""',
64
+ fix: 'Pin to exact version: "@nousresearch/hermes-agent": "1.2.3"',
65
65
  },
66
66
 
67
67
  // ── Missing integrity fields ────────────────────────────────────────────────
@@ -208,6 +208,7 @@ const PATTERNS = [
208
208
  {
209
209
  rule: 'AGENT_CFG_ZERO_WIDTH',
210
210
  title: 'Agent Config: Zero-Width Character Cluster',
211
+ // eslint-disable-next-line no-misleading-character-class
211
212
  regex: /[\u200B\u200C\u200D\uFEFF\u2060]{4,}/g,
212
213
  severity: 'high',
213
214
  cwe: 'CWE-116',
@@ -434,7 +434,7 @@ export class AgenticSupplyChainAgent extends BaseAgent {
434
434
  if (!content) return;
435
435
 
436
436
  // Look for AI plugins in netlify.toml [[plugins]] sections
437
- const pluginMatches = content.matchAll(/\[\[plugins\]\][^\[]*package\s*=\s*["']([^"']+)["']/gs);
437
+ const pluginMatches = content.matchAll(/\[\[plugins\]\][^[]*package\s*=\s*["']([^"']+)["']/gs);
438
438
  for (const m of pluginMatches) {
439
439
  const pkg = m[1];
440
440
  if (!NETLIFY_AI_PLUGINS.has(pkg) && !/ai|llm|openai|anthropic|langchain|vector/.test(pkg)) continue;
@@ -74,7 +74,7 @@ const PATTERNS = [
74
74
  {
75
75
  rule: 'CICD_HARDCODED_SECRET',
76
76
  title: 'CI/CD: Hardcoded Secret in Workflow',
77
- regex: /(?:api[_-]?key|token|password|secret)\s*[:=]\s*["'][a-zA-Z0-9_\-]{20,}["']/gi,
77
+ regex: /(?:api[_-]?key|token|password|secret)\s*[:=]\s*["'][a-zA-Z0-9_-]{20,}["']/gi,
78
78
  severity: 'critical',
79
79
  cwe: 'CWE-798',
80
80
  owasp: 'CICD-SEC-6',
@@ -148,7 +148,7 @@ const PATTERNS = [
148
148
  {
149
149
  rule: 'CICD_ENV_EXFILTRATION',
150
150
  title: 'CI/CD: Potential Secret/Env Exfiltration',
151
- regex: /(?:curl|wget|Invoke-WebRequest|Invoke-RestMethod)\b[^\n]*\$\{\{\s*(?:secrets\.|env\.)[^\}]+\}\}/g,
151
+ regex: /(?:curl|wget|Invoke-WebRequest|Invoke-RestMethod)\b[^\n]*\$\{\{\s*(?:secrets\.|env\.)[^}]+\}\}/g,
152
152
  severity: 'critical',
153
153
  cwe: 'CWE-200',
154
154
  owasp: 'CICD-SEC-6',
@@ -445,7 +445,7 @@ export class DeepAnalyzer {
445
445
  codeContext: this._getFileContext(f),
446
446
  }));
447
447
 
448
- let projectContext = this._buildProjectContext(context);
448
+ const projectContext = this._buildProjectContext(context);
449
449
  const prompt = `Analyze these ${items.length} security findings for taint reachability and exploitability.${projectContext}\n\nFindings:\n${JSON.stringify(items, null, 2)}`;
450
450
 
451
451
  try {
@@ -283,6 +283,45 @@ const PATTERNS = [
283
283
  confidence: 'high',
284
284
  },
285
285
 
286
+ // Hermes v0.13.0 "Tenacity Release" coverage (May 7, 2026):
287
+ // The three Tenacity patches require multi-line analysis and are
288
+ // implemented as structural checks below (checkAuthJsonTOCTOU,
289
+ // checkCronSkillInjection, checkBrowserCloudMetadataFloor).
290
+
291
+ // ────────────────────────────────────────────────────────────────────────────
292
+ // TRACK X — xurl skill / X API integration (xAI xurl guide, May 2026)
293
+ // The xurl skill lets a Hermes agent read and write to X. It is a separate
294
+ // CLI shelled out from the agent, backed by OAuth credentials in ~/.xurl.
295
+ // ────────────────────────────────────────────────────────────────────────────
296
+
297
+ {
298
+ rule: 'HERMES_XURL_SUBPROCESS_INJECTION',
299
+ title: 'Hermes: xurl Command Built From Interpolated Input',
300
+ // exec/spawn of an `xurl …` command string with a ${} interpolation —
301
+ // the agent's natural-language → xurl translation is being assembled as a
302
+ // shell string. Prompt injection then controls xurl flags or shell metachars.
303
+ regex: /(?:exec|execSync|spawn|spawnSync|spawnAsync|shell)\s*\(?\s*`[^`]*\bxurl\b[^`]*\$\{/g,
304
+ severity: 'critical',
305
+ cwe: 'CWE-78',
306
+ owasp: 'ASI03',
307
+ description: 'An `xurl` command is assembled as a shell string with an interpolated value. xurl can post, reply, DM, and manage lists on the linked X account — a prompt injection that reaches this interpolation controls real write actions on a live social account.',
308
+ fix: 'Never build the xurl command as a string. Pass arguments as an argv array (execFile/spawn with an args array), validate every argument against an allowlist, and treat all LLM/user text as untrusted.',
309
+ },
310
+
311
+ {
312
+ rule: 'HERMES_XURL_TOKEN_STORE_EXPOSURE',
313
+ title: 'Hermes: xurl / Hermes Credential Store Copied or Tracked',
314
+ // ~/.xurl (OAuth tokens + client secrets, YAML) or ~/.hermes/auth.json
315
+ // referenced in a Dockerfile COPY/ADD, a cp/rsync/tar, or otherwise
316
+ // moved off the developer machine.
317
+ regex: /(?:COPY|ADD|cp|rsync|scp|tar|mv)\s+[^\n]*(?:\.xurl\b|\.hermes\/auth\.json|\.hermes\b)/g,
318
+ severity: 'critical',
319
+ cwe: 'CWE-538',
320
+ owasp: 'ASI10',
321
+ description: 'A Hermes / xurl credential store (~/.xurl holds OAuth tokens and X API client secrets; ~/.hermes/auth.json holds auto-refreshing provider tokens) is being copied into an image, archive, or another host. These tokens grant durable, refreshable access to the linked accounts.',
322
+ fix: 'Never bake credential stores into images or backups. Mount them at runtime from a secret manager, and add .xurl / .hermes/ / auth.json to .gitignore and .dockerignore.',
323
+ },
324
+
286
325
  ];
287
326
 
288
327
  // =============================================================================
@@ -434,6 +473,218 @@ function checkMemoryFileDeserialization(content, filePath, agent) {
434
473
  return findings;
435
474
  }
436
475
 
476
+ // =============================================================================
477
+ // HERMES v0.13.0 / v2026.5.7 — "TENACITY RELEASE" STRUCTURAL CHECKS
478
+ // =============================================================================
479
+ // Three patches in Hermes Agent v0.13.0 (May 7, 2026) closed P0
480
+ // vulnerabilities that need cross-line analysis to detect:
481
+ // - PRs #21176 + #21194 — TOCTOU on auth.json / MCP OAuth credentials
482
+ // - PR #21350 — Cron task assembles a prompt from skill content
483
+ // without scanning for injection
484
+ // - PR #21228 — Browser tool lacks the cloud-metadata SSRF floor
485
+
486
+ const CRED_PATH_RE = /(auth|credential|token|oauth|\.hermes\/(?:auth|creds))/i;
487
+ const STAT_OR_READ = /fs\.(?:existsSync|statSync|accessSync|readFileSync)\s*\([^)]*\)/g;
488
+ const WRITE_SYNC = /fs\.writeFileSync\s*\(\s*([^,)]*)/g;
489
+ const METADATA_HOSTS = /(?:169\.254\.169\.254|169\.254\.170\.2|fd00:ec2|metadata\.google\.internal|100\.100\.100\.200|metadata\.azure)/i;
490
+
491
+ /**
492
+ * Detect a stat-then-write or read-then-write race on a credential-bearing
493
+ * path. Hermes v0.13.0 PR #21194 closed this for auth.json by switching to
494
+ * atomic write-then-rename via write-file-atomic; PR #21176 closed the same
495
+ * for MCP OAuth credential storage. Detection: a stat/read of a path with
496
+ * "auth"/"credential"/"token"/"oauth"/".hermes/auth" in the argument is
497
+ * followed within 25 lines by a writeFileSync to a similar path, and the
498
+ * file does NOT import `write-file-atomic`.
499
+ */
500
+ function checkAuthJsonTOCTOU(content, filePath, agent) {
501
+ const findings = [];
502
+
503
+ // If the file uses write-file-atomic (or equivalent atomic rename pattern),
504
+ // skip — the TOCTOU window is already closed.
505
+ if (/write-file-atomic|renameSync\s*\(\s*[^,]*\.tmp/i.test(content)) return findings;
506
+
507
+ const lines = content.split('\n');
508
+
509
+ // Find every stat/read on a credential path
510
+ const reads = [];
511
+ let m;
512
+ STAT_OR_READ.lastIndex = 0;
513
+ while ((m = STAT_OR_READ.exec(content)) !== null) {
514
+ if (!CRED_PATH_RE.test(m[0])) continue;
515
+ const line = content.slice(0, m.index).split('\n').length;
516
+ reads.push({ line, text: m[0] });
517
+ }
518
+
519
+ if (reads.length === 0) return findings;
520
+
521
+ // Find every writeFileSync on a credential path
522
+ WRITE_SYNC.lastIndex = 0;
523
+ while ((m = WRITE_SYNC.exec(content)) !== null) {
524
+ const pathArg = m[1] || '';
525
+ if (!CRED_PATH_RE.test(pathArg)) continue;
526
+
527
+ const writeLine = content.slice(0, m.index).split('\n').length;
528
+ const racingRead = reads.find(r => writeLine - r.line >= 0 && writeLine - r.line <= 25);
529
+ if (!racingRead) continue;
530
+
531
+ findings.push(createFinding({
532
+ file: filePath,
533
+ line: writeLine,
534
+ severity: 'high',
535
+ category: agent.category,
536
+ rule: 'HERMES_AUTH_JSON_TOCTOU',
537
+ title: 'Hermes: TOCTOU on auth.json / MCP OAuth Credentials',
538
+ description: `Pattern fixed by Hermes v0.13.0 PRs #21176 + #21194 — TOCTOU window between ${racingRead.text} (line ${racingRead.line}) and fs.writeFileSync(${pathArg.trim().slice(0, 60)}…, …) (line ${writeLine}). A concurrent process can race the write and leave the credential file inconsistent or attacker-controlled.`,
539
+ matched: lines[writeLine - 1]?.trim().slice(0, 240) || '',
540
+ confidence: 'medium',
541
+ cwe: 'CWE-367',
542
+ owasp: 'ASI04',
543
+ fix: 'Write credentials atomically via the write-file-atomic package (or a manual write-to-temp + fsync + renameSync). See PR #21194 in hermes-agent for the reference fix.',
544
+ }));
545
+ }
546
+
547
+ return findings;
548
+ }
549
+
550
+ /**
551
+ * Detect a scheduled task (cron / setInterval / scheduler) that loads
552
+ * a Hermes skill file and assembles its content into a prompt without
553
+ * a scan / sanitize / validate / scanForInjection call in the same
554
+ * function body. Hermes v0.13.0 PR #21350 closes this by scanning the
555
+ * assembled prompt before execution.
556
+ */
557
+ function checkCronSkillInjection(content, filePath, agent) {
558
+ const findings = [];
559
+
560
+ const SCHEDULE_RE = /(cron(?:\.schedule)?|@cron|nodeCron|node[-_]?cron|node[-_]?schedule|scheduler\.(?:schedule|every|at)|setInterval|setTimeout)\s*\(/g;
561
+ const SKILL_LOAD_RE = /(?:readFile(?:Sync)?|fs\.read|loadSkill|skills\.get)\s*\([^)]*(?:skill|\.hermes\/skills|hermes-skills|playbook|\.md)/i;
562
+
563
+ let m;
564
+ SCHEDULE_RE.lastIndex = 0;
565
+ while ((m = SCHEDULE_RE.exec(content)) !== null) {
566
+ // Window: the body of the schedule callback. We approximate as the next
567
+ // ~30 lines after the schedule call.
568
+ const startIdx = m.index + m[0].length;
569
+ const window = content.slice(startIdx, startIdx + 2400);
570
+
571
+ if (!SKILL_LOAD_RE.test(window)) continue;
572
+ // Skip if the same window already runs a scan/sanitize/validate
573
+ if (/(?:scanForInjection|sanitize|validatePrompt|ship[-_]?safe\.?scan|injection[-_]?scan)/i.test(window)) continue;
574
+
575
+ const line = content.slice(0, m.index).split('\n').length;
576
+ findings.push(createFinding({
577
+ file: filePath,
578
+ line,
579
+ severity: 'high',
580
+ category: agent.category,
581
+ rule: 'HERMES_CRON_SKILL_INJECTION',
582
+ title: 'Hermes: Cron Task Loads Skill Content Without Injection Scan',
583
+ description: 'Pattern fixed by Hermes v0.13.0 PR #21350 — a scheduled task loads a Hermes skill file (.md) and assembles its content into a prompt. Skill markdown is attacker-influenceable (anyone with PR access can edit it); without an injection scan on the assembled prompt, the scheduled run can be hijacked by a poisoned skill body.',
584
+ matched: m[0].trim(),
585
+ confidence: 'medium',
586
+ cwe: 'CWE-94',
587
+ owasp: 'ASI01',
588
+ fix: 'Run the assembled prompt through ship-safe scan (or your own prompt-injection scanner) before invoking the agent. Pin the skill commit hash on every scheduled run.',
589
+ }));
590
+ }
591
+
592
+ return findings;
593
+ }
594
+
595
+ /**
596
+ * Detect a browser- or HTTP-fetch tool definition that performs an outbound
597
+ * request without enforcing the cloud-metadata SSRF floor. Hermes v0.13.0
598
+ * PR #21228 closes this in browser-tool hybrid routing by blocking
599
+ * 169.254.169.254 (AWS/Alibaba), 100.100.100.200 (Alibaba),
600
+ * metadata.google.internal (GCP), and 169.254.170.2 (ECS task metadata).
601
+ */
602
+ function checkBrowserCloudMetadataFloor(content, filePath, agent) {
603
+ const findings = [];
604
+
605
+ // If the file already enforces a metadata floor, skip.
606
+ if (METADATA_HOSTS.test(content)) return findings;
607
+
608
+ const TOOL_NAME_RE = /(?:registerTool|server\.tool|addTool|tools\.push|tools\.set)\s*\(\s*['"]?(browser|navigate|web[-_]?browse|fetch[-_]?url|http[-_]?request|browse[-_]?url|webBrowse|fetchUrl|httpRequest)['"]?/gi;
609
+
610
+ let m;
611
+ TOOL_NAME_RE.lastIndex = 0;
612
+ while ((m = TOOL_NAME_RE.exec(content)) !== null) {
613
+ // Window: the tool handler body
614
+ const startIdx = m.index + m[0].length;
615
+ const window = content.slice(startIdx, startIdx + 1500);
616
+
617
+ // Must perform an outbound fetch inside the handler
618
+ if (!/(?:fetch\s*\(|axios\.|got\s*\(|http\.(?:get|request)|requests\.(?:get|post)|urllib\.|httpx\.)/i.test(window)) continue;
619
+
620
+ const line = content.slice(0, m.index).split('\n').length;
621
+ findings.push(createFinding({
622
+ file: filePath,
623
+ line,
624
+ severity: 'high',
625
+ category: agent.category,
626
+ rule: 'HERMES_BROWSER_CLOUD_METADATA_SSRF',
627
+ title: 'Hermes: Browser/HTTP Tool Without Cloud-Metadata SSRF Floor',
628
+ description: 'Pattern fixed by Hermes v0.13.0 PR #21228 — a browser or HTTP tool issues outbound requests without enforcing the cloud-metadata SSRF floor. An attacker who controls the URL via prompt injection can reach 169.254.169.254 (AWS/Alibaba), 100.100.100.200 (Alibaba), metadata.google.internal (GCP), or 169.254.170.2 (ECS task metadata) and exfiltrate IAM credentials.',
629
+ matched: m[0].trim().slice(0, 120),
630
+ confidence: 'medium',
631
+ cwe: 'CWE-918',
632
+ owasp: 'ASI04',
633
+ fix: 'Block these hosts at the tool boundary: 169.254.169.254, fd00:ec2::254, 100.100.100.200, metadata.google.internal, 169.254.170.2. See PR #21228 in hermes-agent for the reference cloud-metadata floor.',
634
+ }));
635
+ }
636
+
637
+ return findings;
638
+ }
639
+
640
+ /**
641
+ * Detect the xurl indirect-injection → action loop. The xurl skill lets a
642
+ * Hermes agent both READ X content (search / timeline / bookmarks — all
643
+ * attacker-controlled) and WRITE to X (post / reply / quote / like / DM).
644
+ * When a single skill, cron task, or source flow does both with no
645
+ * human-approval gate, a poisoned post the agent reads can hijack it into
646
+ * posting on the user's behalf — the canonical OWASP ASI-01/ASI-08 chain.
647
+ */
648
+ function checkXurlReadWriteLoop(content, filePath, agent) {
649
+ const findings = [];
650
+
651
+ // Only relevant if the file actually touches the xurl skill.
652
+ if (!/\bxurl\b|\/xurl\b/i.test(content)) return findings;
653
+
654
+ // xurl READ-class operations — content sources the agent does not control
655
+ const READ_RE = /\bxurl\s+(?:search|bookmarks|timeline|users?|get)\b|\b(?:search\s+for\s+posts|my\s+(?:latest\s+)?timeline|all\s+of\s+my\s+bookmarks|look\s+up\s+@)/i;
656
+ // xurl WRITE-class operations — real, visible actions on the live account
657
+ const WRITE_RE = /\bxurl\s+(?:post|reply|quote|like|unlike|bookmark|unbookmark|dm|delete)\b|\b(?:post\s+["'`]|reply\s+to\s+post|quote\s+post|like\s+post|send\s+a\s+dm)/i;
658
+ // Presence of a human-in-the-loop gate disqualifies the finding
659
+ const GATE_RE = /\b(?:requireApproval|human[-_]?(?:approval|review|confirm)|confirm(?:ation)?\s*(?:gate|required|before)|awaitConfirm|ask\s+(?:the\s+user\s+)?before|dry[-_]?run)\b/i;
660
+
661
+ const hasRead = READ_RE.test(content);
662
+ const hasWrite = WRITE_RE.test(content);
663
+ if (!hasRead || !hasWrite) return findings;
664
+ if (GATE_RE.test(content)) return findings;
665
+
666
+ // Anchor the finding on the first write operation
667
+ const wm = content.match(WRITE_RE);
668
+ const line = wm ? content.slice(0, content.indexOf(wm[0])).split('\n').length : 1;
669
+
670
+ findings.push(createFinding({
671
+ file: filePath,
672
+ line,
673
+ severity: 'critical',
674
+ category: agent.category,
675
+ rule: 'HERMES_XURL_READ_WRITE_LOOP',
676
+ title: 'Hermes: xurl Read-then-Write Loop Without a Human Gate',
677
+ description: 'This file drives the xurl skill to both read X content (search / timeline / bookmarks — all attacker-controlled) and write to X (post / reply / quote / like / DM) with no human-approval gate. A poisoned post the agent reads while summarizing a timeline or bookmark set can hijack it via indirect prompt injection into posting, replying, or DMing on the linked account.',
678
+ matched: wm ? wm[0].trim().slice(0, 120) : 'xurl read + write in one flow',
679
+ confidence: 'medium',
680
+ cwe: 'CWE-94',
681
+ owasp: 'ASI01',
682
+ fix: 'Split read and write into separate, gated steps. Require explicit human approval before any xurl write (post/reply/quote/like/DM). Treat every piece of fetched X content as untrusted input and scan it for injection before it reaches the model.',
683
+ }));
684
+
685
+ return findings;
686
+ }
687
+
437
688
  // =============================================================================
438
689
  // AGENT CLASS
439
690
  // =============================================================================
@@ -487,6 +738,12 @@ export class HermesSecurityAgent extends BaseAgent {
487
738
  findings.push(...checkToolContextForwarding(content, filePath, this));
488
739
  findings.push(...checkSkillFrontmatter(content, filePath, this));
489
740
  findings.push(...checkMemoryFileDeserialization(content, filePath, this));
741
+ // Hermes v0.13.0 "Tenacity Release" coverage
742
+ findings.push(...checkAuthJsonTOCTOU(content, filePath, this));
743
+ findings.push(...checkCronSkillInjection(content, filePath, this));
744
+ findings.push(...checkBrowserCloudMetadataFloor(content, filePath, this));
745
+ // xurl skill / X API integration coverage
746
+ findings.push(...checkXurlReadWriteLoop(content, filePath, this));
490
747
  }
491
748
 
492
749
  return findings;
@@ -523,7 +780,7 @@ export class HermesSecurityAgent extends BaseAgent {
523
780
  if (/\.(js|ts|mjs|cjs|py)$/.test(rel)) {
524
781
  const content = this.readFile(file);
525
782
  if (!content) continue;
526
- if (/(?:hermes[-_]agent|@nousresearch\/hermes|hermes\.config|toolRegistry|registerTool|callTool|spawnAgent|createSubAgent|memory\.store|episodicMemory|semanticMemory|loadManifest)/i.test(content)) {
783
+ if (/(?:hermes[-_]agent|@nousresearch\/hermes|hermes\.config|toolRegistry|registerTool|callTool|spawnAgent|createSubAgent|memory\.store|episodicMemory|semanticMemory|loadManifest|\bxurl\b)/i.test(content)) {
527
784
  hermesFiles.add(file);
528
785
  }
529
786
  }
@@ -41,7 +41,7 @@ export { PolicyEngine } from './policy-engine.js';
41
41
  export { HTMLReporter } from './html-reporter.js';
42
42
 
43
43
  /**
44
- * Create a fully configured orchestrator with all 22 scanning agents.
44
+ * Create a fully configured orchestrator with all 23 scanning agents.
45
45
  * (VerifierAgent and DeepAnalyzer run as post-processors, not in the agent pool.)
46
46
  *
47
47
  * Plugin system: if rootPath is provided, custom agents from
@@ -274,7 +274,7 @@ export class LegalRiskAgent extends BaseAgent {
274
274
 
275
275
  const lines = (this.readFile(goModPath) || '').split('\n');
276
276
  for (const line of lines) {
277
- const m = line.trim().match(/^([\w./\-]+)\s+(v[\d.]+)/);
277
+ const m = line.trim().match(/^([\w./-]+)\s+(v[\d.]+)/);
278
278
  if (!m) continue;
279
279
  const [, name, version] = m;
280
280
 
@@ -45,7 +45,8 @@ const PATTERNS = [
45
45
  severity: 'critical',
46
46
  cwe: 'CWE-94',
47
47
  owasp: 'A03:2021',
48
- description: 'Tool registration from external/user input allows attackers to inject malicious tool definitions (tool poisoning attack).',
48
+ cves: ['CVE-2026-26118'],
49
+ description: 'Pattern exploited by CVE-2026-26118 (Microsoft MCP tool hijacking). Tool registration from external/user input allows attackers to inject malicious tool definitions (tool poisoning attack).',
49
50
  fix: 'Only register tools from trusted, hardcoded definitions. Never accept tool definitions from user input.',
50
51
  },
51
52
 
@@ -57,8 +58,9 @@ const PATTERNS = [
57
58
  severity: 'critical',
58
59
  cwe: 'CWE-306',
59
60
  owasp: 'A07:2021',
61
+ cves: ['CVE-2026-33032'],
60
62
  confidence: 'medium',
61
- description: 'MCP server created without any authentication configuration. Any client can connect and invoke tools.',
63
+ description: 'Pattern exploited by CVE-2026-33032 (nginx-ui MCP unauthenticated RCE, CVSS 9.8 — 2,600+ exposed instances). MCP server created without any authentication configuration. Any client can connect and invoke tools.',
62
64
  fix: 'Add authentication to MCP server transport: API key validation, JWT verification, or OAuth',
63
65
  },
64
66
  {
@@ -81,7 +83,8 @@ const PATTERNS = [
81
83
  severity: 'critical',
82
84
  cwe: 'CWE-78',
83
85
  owasp: 'A03:2021',
84
- description: 'MCP tool handler executes shell commands. If tool arguments are user-influenced via prompt injection, this enables RCE.',
86
+ cves: ['CVE-2026-30615'],
87
+ description: 'Pattern exploited by CVE-2026-30615 (Windsurf prompt-injection → local RCE, zero user interaction). MCP tool handler executes shell commands. If tool arguments are user-influenced via prompt injection, this enables RCE.',
85
88
  fix: 'Avoid shell execution in MCP tools. If necessary, use strict allowlists for commands and validate all arguments.',
86
89
  },
87
90
  {
@@ -111,9 +114,10 @@ const PATTERNS = [
111
114
  severity: 'medium',
112
115
  cwe: 'CWE-918',
113
116
  owasp: 'A10:2021',
117
+ cves: ['CVE-2026-44284'],
114
118
  confidence: 'medium',
115
- description: 'MCP tool makes external HTTP requests. Prompt injection could trigger SSRF via tool arguments.',
116
- fix: 'Validate URLs against allowlist. Block internal/private IP ranges.',
119
+ description: 'Pattern exploited by CVE-2026-44284 (FastGPT MCP SSRF in tool URL handling). MCP tool makes external HTTP requests. Prompt injection could trigger SSRF via tool arguments.',
120
+ fix: 'Validate URLs against allowlist. Block internal/private IP ranges (169.254.169.254, 100.100.100.200, metadata.google.internal).',
117
121
  },
118
122
 
119
123
  // ── Input Injection ──────────────────────────────────────────────────────
@@ -154,6 +154,7 @@ const INJECTION_PATTERNS = [
154
154
  const HIDDEN_CONTENT_PATTERNS = [
155
155
  {
156
156
  // Unicode zero-width chars used to hide instructions
157
+ // eslint-disable-next-line no-misleading-character-class
157
158
  regex: /[\u200B\u200C\u200D\u2060\uFEFF]{3,}/g,
158
159
  rule: 'MEMORY_HIDDEN_UNICODE',
159
160
  title: 'Hidden Unicode Content in Agent File',
@@ -16,7 +16,7 @@ const PATTERNS = [
16
16
  {
17
17
  rule: 'MOBILE_HARDCODED_KEY',
18
18
  title: 'Mobile: Hardcoded API Key in Bundle',
19
- regex: /(?:apiKey|api_key|API_KEY|secret|SECRET)\s*[:=]\s*["'][a-zA-Z0-9_\-]{20,}["']/g,
19
+ regex: /(?:apiKey|api_key|API_KEY|secret|SECRET)\s*[:=]\s*["'][a-zA-Z0-9_-]{20,}["']/g,
20
20
  severity: 'critical',
21
21
  cwe: 'CWE-798',
22
22
  owasp: 'M1',
@@ -39,7 +39,7 @@ const PATTERNS = [
39
39
  {
40
40
  rule: 'PII_IN_LOGGER',
41
41
  title: 'Privacy: PII in Structured Logger',
42
- regex: /(?:logger|winston|pino|bunyan|morgan)\.(?:info|warn|error|debug|log)\s*\([\s\S]{0,200}(?:[\.\[\(]email\b|password|ssn|creditCard|credit_card|phoneNumber|phone_number|dateOfBirth|date_of_birth)/g,
42
+ regex: /(?:logger|winston|pino|bunyan|morgan)\.(?:info|warn|error|debug|log)\s*\([\s\S]{0,200}(?:[.[(]email\b|password|ssn|creditCard|credit_card|phoneNumber|phone_number|dateOfBirth|date_of_birth)/g,
43
43
  severity: 'high',
44
44
  cwe: 'CWE-532',
45
45
  owasp: 'A09:2021',
@@ -112,7 +112,7 @@ export class SwarmOrchestrator {
112
112
  const jsonInstruction = '\n\nOutput a JSON object with exactly these keys: {"findings":[{"agentId":"<agent-id>","file":"<relative-path>","line":<number>,"severity":"critical|high|medium|low","rule":"<rule-id>","title":"<title>","description":"<description>","remediation":"<fix>"}],"agentSummary":[{"agentId":"<agent-id>","findingCount":<number>,"status":"clean|findings"}]}';
113
113
 
114
114
  const text = await this.provider.complete(systemPrompt, prompt + jsonInstruction, { maxTokens: 8192, jsonMode: true });
115
- let raw = null;
115
+ let raw;
116
116
  try {
117
117
  raw = JSON.parse(text || '{}');
118
118
  } catch {
@@ -19,7 +19,7 @@ import { program } from 'commander';
19
19
  import chalk from 'chalk';
20
20
  import { readFileSync } from 'fs';
21
21
  import { fileURLToPath } from 'url';
22
- import { dirname, join } from 'path';
22
+ import { dirname, join, resolve } from 'path';
23
23
  import { scanCommand } from '../commands/scan.js';
24
24
  import { checklistCommand } from '../commands/checklist.js';
25
25
  import { initCommand } from '../commands/init.js';
@@ -638,7 +638,7 @@ How it works:
638
638
  on every audit or watch --deep run.
639
639
  `)
640
640
  .action((action, options) => {
641
- const rootPath = path.resolve(process.cwd());
641
+ const rootPath = resolve(process.cwd());
642
642
  if (action === 'new') {
643
643
  const pluginName = options.args?.[0] || options._name || 'my-rule';
644
644
  try {
@@ -240,6 +240,7 @@ export async function agentFixCommand(targetPath = '.', options = {}) {
240
240
 
241
241
  if (decision === 'q' || decision === 'quit') {
242
242
  console.log(chalk.gray(' Stopping.'));
243
+ // eslint-disable-next-line no-useless-assignment -- read by outer-loop guard at top of next iteration
243
244
  stopped = true;
244
245
  break;
245
246
  }
@@ -447,7 +448,7 @@ RULES:
447
448
  return { ok: true, plan };
448
449
  }
449
450
 
450
- function parseJsonLoose(response) {
451
+ export function parseJsonLoose(response) {
451
452
  if (!response) return null;
452
453
  const cleaned = response.trim()
453
454
  .replace(/^```(?:json)?\s*/i, '')
@@ -464,7 +465,7 @@ function parseJsonLoose(response) {
464
465
  }
465
466
  }
466
467
 
467
- function windowFileContent(content, line) {
468
+ export function windowFileContent(content, line) {
468
469
  if (content.length <= 8000) return content;
469
470
  if (!line) return content.slice(0, 8000);
470
471
  const lines = content.split('\n');
@@ -477,7 +478,7 @@ function windowFileContent(content, line) {
477
478
  // PLAN VALIDATION
478
479
  // =============================================================================
479
480
 
480
- function validatePlan(root, plan) {
481
+ export function validatePlan(root, plan) {
481
482
  if (!Array.isArray(plan.files) || plan.files.length === 0) {
482
483
  return { ok: false, reason: 'no files in plan' };
483
484
  }
@@ -540,7 +541,7 @@ function validatePlan(root, plan) {
540
541
 
541
542
  // Try exact match first, then whitespace-normalized match if exact misses.
542
543
  // Returns { kind: 'unique'|'ambiguous'|'missing', matched, count }
543
- function locateFindString(haystack, needle) {
544
+ export function locateFindString(haystack, needle) {
544
545
  const exact = countOccurrences(haystack, needle);
545
546
  if (exact === 1) return { kind: 'unique', matched: needle, count: 1 };
546
547
  if (exact > 1) return { kind: 'ambiguous', matched: needle, count: exact };
@@ -572,7 +573,7 @@ function locateFindString(haystack, needle) {
572
573
  return { kind: 'missing', matched: null, count: 0 };
573
574
  }
574
575
 
575
- function countOccurrences(haystack, needle) {
576
+ export function countOccurrences(haystack, needle) {
576
577
  if (!needle) return 0;
577
578
  let count = 0, idx = 0;
578
579
  while ((idx = haystack.indexOf(needle, idx)) !== -1) { count++; idx += needle.length; }
@@ -800,7 +801,7 @@ async function openPullRequest(root, branch, applied) {
800
801
  const title = `Security fixes: ${applied.length} file(s)`;
801
802
 
802
803
  console.log(chalk.gray(' Opening PR...'));
803
- let prUrl = null;
804
+ let prUrl;
804
805
  try {
805
806
  prUrl = execFileSync('gh', ['pr', 'create', '--title', title, '--body', body], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
806
807
  console.log(chalk.green(` PR opened: ${prUrl}`));
@@ -213,7 +213,7 @@ export async function agentCommand(targetPath = '.', options = {}) {
213
213
  console.log(chalk.white(` ${step++}.`) + chalk.gray(' Fill in .env with fresh values from your providers'));
214
214
  }
215
215
  if (vulnCount > 0) {
216
- console.log(chalk.white(` ${step++}.`) + chalk.gray(' Apply the code fixes shown above, then re-run: ') + chalk.cyan('npx ship-safe agent .'));
216
+ console.log(chalk.white(` ${step}.`) + chalk.gray(' Apply the code fixes shown above, then re-run: ') + chalk.cyan('npx ship-safe agent .'));
217
217
  }
218
218
  console.log();
219
219
  }
@@ -96,7 +96,7 @@ export async function auditCommand(targetPath = '.', options = {}) {
96
96
  // ── Cache Layer ──────────────────────────────────────────────────────────
97
97
  const useCache = options.cache !== false;
98
98
  const cache = new CacheManager(absolutePath);
99
- let cacheData = useCache ? cache.load() : null;
99
+ const cacheData = useCache ? cache.load() : null;
100
100
  let cacheDiff = null;
101
101
  let allFiles = [];
102
102
 
@@ -923,7 +923,7 @@ function outputSARIF(findings, rootPath) {
923
923
 
924
924
  // Walk up from `start` looking for `name`. Returns the absolute path or null.
925
925
  // Bounded to 8 ancestors to avoid runaway loops on weird filesystems.
926
- function findUpwards(start, name) {
926
+ export function findUpwards(start, name) {
927
927
  let dir = path.resolve(start);
928
928
  for (let i = 0; i < 8; i++) {
929
929
  const candidate = path.join(dir, name);
@@ -276,10 +276,9 @@ async function handleAgentFiles(targetDir, force, results) {
276
276
  results.skipped.push(`${label} (already contains ship-safe rules)`);
277
277
  continue;
278
278
  }
279
- if (force || true) { // always append unless already present
280
- fs.writeFileSync(targetPath, existing.trimEnd() + '\n' + AGENT_SECTION);
281
- results.merged.push(label);
282
- }
279
+ // Always append (ship-safe marker check above already short-circuits the no-op case)
280
+ fs.writeFileSync(targetPath, existing.trimEnd() + '\n' + AGENT_SECTION);
281
+ results.merged.push(label);
283
282
  } else {
284
283
  // Ensure parent directory exists (e.g. .github/)
285
284
  const parentDir = path.dirname(targetPath);
@@ -166,7 +166,10 @@ export async function queryOSV(deps) {
166
166
  } catch (err) {
167
167
  // Network error — return what we have so far
168
168
  if (allResults.length === 0) {
169
- throw new Error(`Failed to reach OSV.dev: ${err.message}. Run with --offline to skip live checks.`);
169
+ throw new Error(
170
+ `Failed to reach OSV.dev: ${err.message}. Run with --offline to skip live checks.`,
171
+ { cause: err },
172
+ );
170
173
  }
171
174
  }
172
175
  }
@@ -54,7 +54,7 @@ export async function openclawCommand(targetPath = '.', options = {}) {
54
54
  mcpScanner.analyze(context),
55
55
  ]);
56
56
 
57
- let findings = [...configFindings, ...mcpFindings];
57
+ const findings = [...configFindings, ...mcpFindings];
58
58
 
59
59
  // Threat intel enrichment
60
60
  const intel = ThreatIntel.load();
@@ -40,8 +40,8 @@ export async function redTeamCommand(targetPath = '.', options = {}) {
40
40
  console.log();
41
41
 
42
42
  let findings = [];
43
- let recon = {};
44
- let agentResults = [];
43
+ let recon;
44
+ let agentResults;
45
45
 
46
46
  // ── 1a. Swarm mode (parallel execution via best available provider) ────────
47
47
  if (options.swarm) {
@@ -125,7 +125,7 @@ function computeReplacement(matched, envRef) {
125
125
  }
126
126
 
127
127
  // Case 2: unquoted assignment — key = value (no quotes around value)
128
- const unquotedAssignment = matched.match(/^(.*?[:=]\s*)([^\s"'<>\[\]{},;]{8,})(\s*)$/s);
128
+ const unquotedAssignment = matched.match(/^(.*?[:=]\s*)([^\s"'<>[\]{},;]{8,})(\s*)$/s);
129
129
  if (unquotedAssignment) {
130
130
  const [, prefix, secretValue, suffix] = unquotedAssignment;
131
131
  return { replacement: prefix + envRef + suffix, secretValue };
@@ -105,7 +105,7 @@ const MCP_TOOL_PATTERNS = [
105
105
  // ── Encoded payload in description ────────────────────────────────────────
106
106
  {
107
107
  name: 'Encoded payload block',
108
- regex: /[A-Za-z0-9+\/]{60,}={0,2}/g,
108
+ regex: /[A-Za-z0-9+/]{60,}={0,2}/g,
109
109
  severity: 'medium',
110
110
  target: 'any',
111
111
  },
@@ -99,7 +99,7 @@ const SKILL_PATTERNS = [
99
99
  { name: 'Dynamic code evaluation', regex: /(?:eval\s*\(|new\s+Function\s*\(|exec\s*\(|compile\s*\()/gi, severity: 'high' },
100
100
  { name: 'Crypto operations', regex: /(?:crypto\.createCipher|crypto\.createDecipher|CryptoJS|forge\.cipher)/gi, severity: 'medium' },
101
101
  { name: 'Network listener', regex: /(?:createServer|listen\s*\(\s*\d|bind\s*\(\s*['"]0\.0\.0\.0)/gi, severity: 'high' },
102
- { name: 'Encoded payload block', regex: /[A-Za-z0-9+\/]{60,}={0,2}/g, severity: 'medium' },
102
+ { name: 'Encoded payload block', regex: /[A-Za-z0-9+/]{60,}={0,2}/g, severity: 'medium' },
103
103
 
104
104
  // ── ToxicSkills patterns (Snyk research — 36% of agent skills affected) ──
105
105
  // Silent curl exfiltration: skill instructs agent to silently send data
@@ -25,10 +25,11 @@ import { printBanner } from '../utils/output.js';
25
25
 
26
26
  function stripAnsi(str) {
27
27
  // Remove all ANSI escape sequences (colors, cursor moves, clears, etc.)
28
+ // eslint-disable-next-line no-control-regex
28
29
  return str
29
- .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
30
- .replace(/\x1b\][^\x07]*\x07/g, '')
31
- .replace(/\x1b[()][AB012]/g, '')
30
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') // eslint-disable-line no-control-regex
31
+ .replace(/\x1b\][^\x07]*\x07/g, '') // eslint-disable-line no-control-regex
32
+ .replace(/\x1b[()][AB012]/g, '') // eslint-disable-line no-control-regex
32
33
  .replace(/\x9b[0-9;]*[A-Za-z]/g, '');
33
34
  }
34
35
 
@@ -95,7 +95,7 @@ export async function undoCommand(targetPath = '.', options = {}) {
95
95
  }
96
96
  }
97
97
 
98
- function reverseEntry(root, entry) {
98
+ export function reverseEntry(root, entry) {
99
99
  const plan = entry.plan;
100
100
  if (!plan || !Array.isArray(plan.files) || plan.files.length === 0) {
101
101
  throw new Error('entry has no plan to reverse');
@@ -332,7 +332,7 @@ async function watchStateful(absolutePath, options = {}) {
332
332
  await watcher.setBaseline(recon, files);
333
333
  console.log(chalk.green(` Baseline set (${watcher.provider.name} / ${watcher.provider.model}). Watching...\n`));
334
334
 
335
- let pendingFiles = new Set();
335
+ const pendingFiles = new Set();
336
336
  let debounceTimer = null;
337
337
  let allFindings = [];
338
338
 
@@ -454,7 +454,7 @@ async function watchDeep(absolutePath, options = {}) {
454
454
  } catch { recon = {}; }
455
455
  console.log(chalk.gray(' Recon complete. Watching...\n'));
456
456
 
457
- let pendingFiles = new Set();
457
+ const pendingFiles = new Set();
458
458
  let debounceTimer = null;
459
459
  let scanCount = 0;
460
460
 
@@ -162,7 +162,7 @@ export const HIGH_PATTERNS = [
162
162
  {
163
163
  name: 'Generic high-entropy secret assignment',
164
164
  severity: 'high',
165
- re: /(?:token|secret|api_key|apikey)\s*[:=]\s*["']([A-Za-z0-9+/=_\-]{32,})["']/i,
165
+ re: /(?:token|secret|api_key|apikey)\s*[:=]\s*["']([A-Za-z0-9+/=_-]{32,})["']/i,
166
166
  checkEntropy: true, // only report if entropy > threshold
167
167
  },
168
168
  ];
@@ -479,6 +479,18 @@ export const SECRET_PATTERNS = [
479
479
  severity: 'high',
480
480
  description: 'xAI API keys grant access to Grok models and incur usage charges on your account.'
481
481
  },
482
+ {
483
+ name: 'X API OAuth Client Secret',
484
+ pattern: /(?:client[_-]?secret|consumer[_-]?secret)["'\s]*[:=]["'\s]*([A-Za-z0-9_-]{40,60})["']?/gi,
485
+ severity: 'critical',
486
+ description: 'An X (Twitter) API OAuth 2.0 client secret. With the matching client ID it lets an attacker complete the OAuth flow as your app — used by the xurl skill to post, reply, and DM on the linked account.'
487
+ },
488
+ {
489
+ name: 'X API v2 Bearer Token',
490
+ pattern: /AAAAAAAAAAAAAAAAAAAAA[A-Za-z0-9%]{60,}/g,
491
+ severity: 'critical',
492
+ description: 'An X (Twitter) API v2 bearer token. Grants app-level read access to the X API; if scoped to a user context it also enables writes.'
493
+ },
482
494
  {
483
495
  name: 'Tavily API Key',
484
496
  pattern: /tvly-[a-zA-Z0-9]{32,}/g,
@@ -691,14 +703,14 @@ export const SECRET_PATTERNS = [
691
703
  // =========================================================================
692
704
  {
693
705
  name: 'Generic API Key Assignment',
694
- pattern: /["']?(?:api[_-]?key|apikey)["']?\s*[:=]\s*["']([a-zA-Z0-9_\-]{20,})["']/gi,
706
+ pattern: /["']?(?:api[_-]?key|apikey)["']?\s*[:=]\s*["']([a-zA-Z0-9_-]{20,})["']/gi,
695
707
  severity: 'medium',
696
708
  requiresEntropyCheck: true,
697
709
  description: 'Hardcoded API keys should be moved to environment variables.'
698
710
  },
699
711
  {
700
712
  name: 'Generic Secret Assignment',
701
- pattern: /["']?(?:secret|secret[_-]?key)["']?\s*[:=]\s*["']([a-zA-Z0-9_\-]{20,})["']/gi,
713
+ pattern: /["']?(?:secret|secret[_-]?key)["']?\s*[:=]\s*["']([a-zA-Z0-9_-]{20,})["']/gi,
702
714
  severity: 'medium',
703
715
  requiresEntropyCheck: true,
704
716
  description: 'Hardcoded secrets should be moved to environment variables.'
@@ -719,7 +731,7 @@ export const SECRET_PATTERNS = [
719
731
  },
720
732
  {
721
733
  name: 'Bearer Token in Code',
722
- pattern: /["']Bearer\s+[a-zA-Z0-9_\-\.=]{20,}["']/gi,
734
+ pattern: /["']Bearer\s+[a-zA-Z0-9_\-.=]{20,}["']/gi,
723
735
  severity: 'medium',
724
736
  requiresEntropyCheck: true,
725
737
  description: 'Hardcoded bearer tokens should not be in source code.'
@@ -238,7 +238,7 @@ export class SecretsVerifier {
238
238
  if (assigned) return assigned[1];
239
239
 
240
240
  // If the match itself looks like a token, use it
241
- if (/^[a-zA-Z0-9_\-]{20,}$/.test(matched)) return matched;
241
+ if (/^[a-zA-Z0-9_-]{20,}$/.test(matched)) return matched;
242
242
 
243
243
  return null;
244
244
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ship-safe",
3
- "version": "9.2.4",
3
+ "version": "9.3.1",
4
4
  "description": "AI-powered multi-agent security platform. 23 agents scan 80+ attack classes including AI integration supply chain (Vercel-class attacks), Hermes Agent deployments (ASI-01–ASI-10), tool registry poisoning, function-call injection, skill permission drift, and agent attestation. Ship Safe × Hermes Agent.",
5
5
  "main": "cli/index.js",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "scripts": {
11
11
  "test": "node --test cli/__tests__/*.test.js",
12
12
  "lint": "eslint cli/",
13
+ "lint:fix": "eslint cli/ --fix",
13
14
  "ship-safe": "node cli/bin/ship-safe.js"
14
15
  },
15
16
  "keywords": [
@@ -65,5 +66,10 @@
65
66
  "fast-glob": "^3.3.3",
66
67
  "ora": "^8.0.1",
67
68
  "write-file-atomic": "^7.0.0"
69
+ },
70
+ "devDependencies": {
71
+ "@eslint/js": "^10.0.1",
72
+ "eslint": "^10.3.0",
73
+ "globals": "^17.6.0"
68
74
  }
69
75
  }