ship-safe 9.2.3 → 9.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.0 · 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,11 @@ 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
+
286
291
  ];
287
292
 
288
293
  // =============================================================================
@@ -434,6 +439,170 @@ function checkMemoryFileDeserialization(content, filePath, agent) {
434
439
  return findings;
435
440
  }
436
441
 
442
+ // =============================================================================
443
+ // HERMES v0.13.0 / v2026.5.7 — "TENACITY RELEASE" STRUCTURAL CHECKS
444
+ // =============================================================================
445
+ // Three patches in Hermes Agent v0.13.0 (May 7, 2026) closed P0
446
+ // vulnerabilities that need cross-line analysis to detect:
447
+ // - PRs #21176 + #21194 — TOCTOU on auth.json / MCP OAuth credentials
448
+ // - PR #21350 — Cron task assembles a prompt from skill content
449
+ // without scanning for injection
450
+ // - PR #21228 — Browser tool lacks the cloud-metadata SSRF floor
451
+
452
+ const CRED_PATH_RE = /(auth|credential|token|oauth|\.hermes\/(?:auth|creds))/i;
453
+ const STAT_OR_READ = /fs\.(?:existsSync|statSync|accessSync|readFileSync)\s*\([^)]*\)/g;
454
+ const WRITE_SYNC = /fs\.writeFileSync\s*\(\s*([^,)]*)/g;
455
+ const METADATA_HOSTS = /(?:169\.254\.169\.254|169\.254\.170\.2|fd00:ec2|metadata\.google\.internal|100\.100\.100\.200|metadata\.azure)/i;
456
+
457
+ /**
458
+ * Detect a stat-then-write or read-then-write race on a credential-bearing
459
+ * path. Hermes v0.13.0 PR #21194 closed this for auth.json by switching to
460
+ * atomic write-then-rename via write-file-atomic; PR #21176 closed the same
461
+ * for MCP OAuth credential storage. Detection: a stat/read of a path with
462
+ * "auth"/"credential"/"token"/"oauth"/".hermes/auth" in the argument is
463
+ * followed within 25 lines by a writeFileSync to a similar path, and the
464
+ * file does NOT import `write-file-atomic`.
465
+ */
466
+ function checkAuthJsonTOCTOU(content, filePath, agent) {
467
+ const findings = [];
468
+
469
+ // If the file uses write-file-atomic (or equivalent atomic rename pattern),
470
+ // skip — the TOCTOU window is already closed.
471
+ if (/write-file-atomic|renameSync\s*\(\s*[^,]*\.tmp/i.test(content)) return findings;
472
+
473
+ const lines = content.split('\n');
474
+
475
+ // Find every stat/read on a credential path
476
+ const reads = [];
477
+ let m;
478
+ STAT_OR_READ.lastIndex = 0;
479
+ while ((m = STAT_OR_READ.exec(content)) !== null) {
480
+ if (!CRED_PATH_RE.test(m[0])) continue;
481
+ const line = content.slice(0, m.index).split('\n').length;
482
+ reads.push({ line, text: m[0] });
483
+ }
484
+
485
+ if (reads.length === 0) return findings;
486
+
487
+ // Find every writeFileSync on a credential path
488
+ WRITE_SYNC.lastIndex = 0;
489
+ while ((m = WRITE_SYNC.exec(content)) !== null) {
490
+ const pathArg = m[1] || '';
491
+ if (!CRED_PATH_RE.test(pathArg)) continue;
492
+
493
+ const writeLine = content.slice(0, m.index).split('\n').length;
494
+ const racingRead = reads.find(r => writeLine - r.line >= 0 && writeLine - r.line <= 25);
495
+ if (!racingRead) continue;
496
+
497
+ findings.push(createFinding({
498
+ file: filePath,
499
+ line: writeLine,
500
+ severity: 'high',
501
+ category: agent.category,
502
+ rule: 'HERMES_AUTH_JSON_TOCTOU',
503
+ title: 'Hermes: TOCTOU on auth.json / MCP OAuth Credentials',
504
+ 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.`,
505
+ matched: lines[writeLine - 1]?.trim().slice(0, 240) || '',
506
+ confidence: 'medium',
507
+ cwe: 'CWE-367',
508
+ owasp: 'ASI04',
509
+ 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.',
510
+ }));
511
+ }
512
+
513
+ return findings;
514
+ }
515
+
516
+ /**
517
+ * Detect a scheduled task (cron / setInterval / scheduler) that loads
518
+ * a Hermes skill file and assembles its content into a prompt without
519
+ * a scan / sanitize / validate / scanForInjection call in the same
520
+ * function body. Hermes v0.13.0 PR #21350 closes this by scanning the
521
+ * assembled prompt before execution.
522
+ */
523
+ function checkCronSkillInjection(content, filePath, agent) {
524
+ const findings = [];
525
+
526
+ const SCHEDULE_RE = /(cron(?:\.schedule)?|@cron|nodeCron|node[-_]?cron|node[-_]?schedule|scheduler\.(?:schedule|every|at)|setInterval|setTimeout)\s*\(/g;
527
+ const SKILL_LOAD_RE = /(?:readFile(?:Sync)?|fs\.read|loadSkill|skills\.get)\s*\([^)]*(?:skill|\.hermes\/skills|hermes-skills|playbook|\.md)/i;
528
+
529
+ let m;
530
+ SCHEDULE_RE.lastIndex = 0;
531
+ while ((m = SCHEDULE_RE.exec(content)) !== null) {
532
+ // Window: the body of the schedule callback. We approximate as the next
533
+ // ~30 lines after the schedule call.
534
+ const startIdx = m.index + m[0].length;
535
+ const window = content.slice(startIdx, startIdx + 2400);
536
+
537
+ if (!SKILL_LOAD_RE.test(window)) continue;
538
+ // Skip if the same window already runs a scan/sanitize/validate
539
+ if (/(?:scanForInjection|sanitize|validatePrompt|ship[-_]?safe\.?scan|injection[-_]?scan)/i.test(window)) continue;
540
+
541
+ const line = content.slice(0, m.index).split('\n').length;
542
+ findings.push(createFinding({
543
+ file: filePath,
544
+ line,
545
+ severity: 'high',
546
+ category: agent.category,
547
+ rule: 'HERMES_CRON_SKILL_INJECTION',
548
+ title: 'Hermes: Cron Task Loads Skill Content Without Injection Scan',
549
+ 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.',
550
+ matched: m[0].trim(),
551
+ confidence: 'medium',
552
+ cwe: 'CWE-94',
553
+ owasp: 'ASI01',
554
+ 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.',
555
+ }));
556
+ }
557
+
558
+ return findings;
559
+ }
560
+
561
+ /**
562
+ * Detect a browser- or HTTP-fetch tool definition that performs an outbound
563
+ * request without enforcing the cloud-metadata SSRF floor. Hermes v0.13.0
564
+ * PR #21228 closes this in browser-tool hybrid routing by blocking
565
+ * 169.254.169.254 (AWS/Alibaba), 100.100.100.200 (Alibaba),
566
+ * metadata.google.internal (GCP), and 169.254.170.2 (ECS task metadata).
567
+ */
568
+ function checkBrowserCloudMetadataFloor(content, filePath, agent) {
569
+ const findings = [];
570
+
571
+ // If the file already enforces a metadata floor, skip.
572
+ if (METADATA_HOSTS.test(content)) return findings;
573
+
574
+ 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;
575
+
576
+ let m;
577
+ TOOL_NAME_RE.lastIndex = 0;
578
+ while ((m = TOOL_NAME_RE.exec(content)) !== null) {
579
+ // Window: the tool handler body
580
+ const startIdx = m.index + m[0].length;
581
+ const window = content.slice(startIdx, startIdx + 1500);
582
+
583
+ // Must perform an outbound fetch inside the handler
584
+ if (!/(?:fetch\s*\(|axios\.|got\s*\(|http\.(?:get|request)|requests\.(?:get|post)|urllib\.|httpx\.)/i.test(window)) continue;
585
+
586
+ const line = content.slice(0, m.index).split('\n').length;
587
+ findings.push(createFinding({
588
+ file: filePath,
589
+ line,
590
+ severity: 'high',
591
+ category: agent.category,
592
+ rule: 'HERMES_BROWSER_CLOUD_METADATA_SSRF',
593
+ title: 'Hermes: Browser/HTTP Tool Without Cloud-Metadata SSRF Floor',
594
+ 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.',
595
+ matched: m[0].trim().slice(0, 120),
596
+ confidence: 'medium',
597
+ cwe: 'CWE-918',
598
+ owasp: 'ASI04',
599
+ 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.',
600
+ }));
601
+ }
602
+
603
+ return findings;
604
+ }
605
+
437
606
  // =============================================================================
438
607
  // AGENT CLASS
439
608
  // =============================================================================
@@ -487,6 +656,10 @@ export class HermesSecurityAgent extends BaseAgent {
487
656
  findings.push(...checkToolContextForwarding(content, filePath, this));
488
657
  findings.push(...checkSkillFrontmatter(content, filePath, this));
489
658
  findings.push(...checkMemoryFileDeserialization(content, filePath, this));
659
+ // Hermes v0.13.0 "Tenacity Release" coverage
660
+ findings.push(...checkAuthJsonTOCTOU(content, filePath, this));
661
+ findings.push(...checkCronSkillInjection(content, filePath, this));
662
+ findings.push(...checkBrowserCloudMetadataFloor(content, filePath, this));
490
663
  }
491
664
 
492
665
  return findings;
@@ -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,14 +58,15 @@ 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
  {
65
67
  rule: 'MCP_STDIO_NO_SANDBOX',
66
68
  title: 'MCP: stdio Transport Without Sandbox',
67
- regex: /(?:StdioServerTransport|stdio|transport.*stdio)/g,
69
+ regex: /(?:StdioServerTransport|new\s+StdioTransport|transport\s*[:=]\s*['"]stdio['"]|StdioClientTransport)/g,
68
70
  severity: 'medium',
69
71
  cwe: 'CWE-269',
70
72
  owasp: 'A04:2021',
@@ -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);
@@ -241,10 +241,10 @@ function buildSARIF(findings, rootPath) {
241
241
  locations: [{
242
242
  physicalLocation: {
243
243
  artifactLocation: {
244
- uri: path.relative(rootPath, f.file).replace(/\\/g, '/'),
244
+ uri: path.relative(rootPath, f.file).replace(/\\/g, '/').replace(/\[/g, '%5B').replace(/\]/g, '%5D'),
245
245
  uriBaseId: '%SRCROOT%',
246
246
  },
247
- region: { startLine: f.line, startColumn: f.column || 1 },
247
+ region: { startLine: Math.max(1, f.line || 1), startColumn: f.column || 1 },
248
248
  },
249
249
  }],
250
250
  })),
@@ -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
  ];