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 +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 +258 -1
- package/cli/agents/index.js +1 -1
- package/cli/agents/legal-risk-agent.js +1 -1
- package/cli/agents/mcp-security-agent.js +9 -5
- 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/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 +15 -3
- package/cli/utils/secrets-verifier.js +1 -1
- package/package.json +7 -1
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,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
|
}
|
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,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
|
-
|
|
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/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
|
];
|
package/cli/utils/patterns.js
CHANGED
|
@@ -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_
|
|
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_
|
|
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_
|
|
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_
|
|
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.
|
|
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
|
}
|