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 +1 -1
- package/cli/agents/agent-attestation-agent.js +1 -1
- package/cli/agents/agent-config-scanner.js +1 -0
- package/cli/agents/agentic-supply-chain-agent.js +1 -1
- package/cli/agents/cicd-scanner.js +2 -2
- package/cli/agents/deep-analyzer.js +1 -1
- package/cli/agents/hermes-security-agent.js +173 -0
- package/cli/agents/index.js +1 -1
- package/cli/agents/legal-risk-agent.js +1 -1
- package/cli/agents/mcp-security-agent.js +10 -6
- package/cli/agents/memory-poisoning-agent.js +1 -0
- package/cli/agents/mobile-scanner.js +1 -1
- package/cli/agents/pii-compliance-agent.js +1 -1
- package/cli/agents/swarm-orchestrator.js +1 -1
- package/cli/bin/ship-safe.js +2 -2
- package/cli/commands/agent-fix.js +7 -6
- package/cli/commands/agent.js +1 -1
- package/cli/commands/audit.js +2 -2
- package/cli/commands/ci.js +2 -2
- package/cli/commands/init.js +3 -4
- package/cli/commands/live-advisories.js +4 -1
- package/cli/commands/openclaw.js +1 -1
- package/cli/commands/red-team.js +2 -2
- package/cli/commands/remediate.js +1 -1
- package/cli/commands/scan-mcp.js +1 -1
- package/cli/commands/scan-skill.js +1 -1
- package/cli/commands/team-report.js +4 -3
- package/cli/commands/undo.js +1 -1
- package/cli/commands/watch.js +2 -2
- package/cli/hooks/patterns.js +1 -1
- package/cli/utils/patterns.js +3 -3
- package/cli/utils/secrets-verifier.js +1 -1
- package/package.json +7 -1
- package/cli/.ship-safe/context.json +0 -8157
- package/cli/.ship-safe/history.json +0 -190
package/README.md
CHANGED
|
@@ -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: "
|
|
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\]\][
|
|
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_
|
|
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\.)[
|
|
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
|
-
|
|
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;
|
package/cli/agents/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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|
|
|
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
|
-
|
|
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_
|
|
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}(?:[
|
|
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
|
|
115
|
+
let raw;
|
|
116
116
|
try {
|
|
117
117
|
raw = JSON.parse(text || '{}');
|
|
118
118
|
} catch {
|
package/cli/bin/ship-safe.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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}`));
|
package/cli/commands/agent.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/cli/commands/audit.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/cli/commands/ci.js
CHANGED
|
@@ -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
|
})),
|
package/cli/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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(
|
|
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
|
}
|
package/cli/commands/openclaw.js
CHANGED
|
@@ -54,7 +54,7 @@ export async function openclawCommand(targetPath = '.', options = {}) {
|
|
|
54
54
|
mcpScanner.analyze(context),
|
|
55
55
|
]);
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
const findings = [...configFindings, ...mcpFindings];
|
|
58
58
|
|
|
59
59
|
// Threat intel enrichment
|
|
60
60
|
const intel = ThreatIntel.load();
|
package/cli/commands/red-team.js
CHANGED
|
@@ -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"'
|
|
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 };
|
package/cli/commands/scan-mcp.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/cli/commands/undo.js
CHANGED
|
@@ -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');
|
package/cli/commands/watch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
457
|
+
const pendingFiles = new Set();
|
|
458
458
|
let debounceTimer = null;
|
|
459
459
|
let scanCount = 0;
|
|
460
460
|
|
package/cli/hooks/patterns.js
CHANGED
|
@@ -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+/=_
|
|
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
|
];
|