使用说明
《Postmark-MCP NPM 事件:发生了什么、为何重要以及如何追踪排查》🧐📧
长文速读(TL;DR)状态: 如果 postmark-mcp@1.0.16 如果在你的环境中发现(异常情况),请将其视为已受威胁,并立即采取补救措施。 🚨(警报/紧急情况的符号,无直接对应中文翻译,可理解为“警报”或“紧急情况”)
执行摘要
最近的一则披露显示,一个恶意的npm包以 postmark-mcp (用于Postmark的MCP连接器)在发布版本中包含了一个后门 1.0.16 该程序会在发送电子邮件时,悄无声息地将邮件抄送(BCC)至攻击者控制的地址。发现此问题后,该程序包已从注册表中移除,但已安装该版本的系统仍可能在泄露数据。这是一种典型的供应链攻击,影响了连接器工具——其影响重大,因为这些库处理电子邮件(令牌、密码重置、发票)。 😬
快速事实与影响统计
- 每周下载量(npm): ~1.5K+ ——这意味着它已被纳入活跃的开发工作流程中,并且可以被许多项目间接包含进来。 📥
- 预计影响: 保守估计表明 3,000至15,000封电子邮件 本有可能被泄露 日常的;每天的起源于大约 300个组织 (基于观察到的集成情况和下载量进行估算)。这些数字表明,即使是一个下载量适中的软件包,也可能存在大量的数据泄露风险。 📈📨(上升趋势,信件)
- 妥协的性质: 将邮件静默发送至攻击者控制的邮箱,并将数据泄露至攻击者域名。
- 为何这很危险: 电子邮件经常携带令牌、凭据、密码重置信息以及个人可识别信息(PII)——因此,窃取邮件意味着可以广泛访问敏感数据。
妥协指标(Indicators of Compromise,简称IOCs)
- 包装:
postmark-mcp(npm) - 恶意版本:
1.0.16并且后来(恶意发布) - 后门邮件(目的地):
phan@giftshop[.]club - 领域:
giftshop[.]club
如果你看到流量到
giftshop.club或转发至邮件phan@giftshop.club,将其视为高度可疑。 🔍
为何这次攻击值得关注(技术层面概述)
MCP连接器是人工智能/电子邮件工具中可信赖的组件。它们:
- 处理敏感邮件内容(令牌、发票、内部消息)。
- 这些(方案/项目)往往未经深入审查就被纳入项目中。
- 可以仅通过最小的代码更改(一行代码即可通过BCC捕获所有内容)来传输大量数据。 结果:中等规模的下载包仍可能导致严重违规。
防守战术手册——当前应采取的行动
以下是两个实用的扫描工具(Python & PowerShell),用于发现 postmark-mcp 在你的代码库或环境中发生的事件。从工作区根目录运行它们(例如。, ~/Projects) 以识别直接安装、锁定文件引用或明确提及。
**如果你检测到版本
1.0.16**立即遵循以下修复检查清单。
Python 扫描器(另存为 scan_postmark_mcp.py)
递归扫描以下内容:
package-lock.json/npm-shrinkwrap.json依存树package.json条目;条目数node_modules/postmark-mcp/package.json(已安装的副本)- 原始文件文本匹配在
.json,.js,.ts,.lock,.md,.txt
#!/usr/bin/env python3
"""
scan_postmark_mcp.py
Usage:
python3 scan_postmark_mcp.py [start_path]
Scans recursively for evidence of "postmark-mcp" and flags version 1.0.16 as compromised.
"""
import sys, os, json, re
START = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
TARGET = "postmark-mcp"
COMPROMISED_VERSION = "1.0.16"
findings = []
def check_package_json_file(path):
try:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
data = json.load(f)
except Exception:
return
for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
deps = data.get(key) or {}
if isinstance(deps, dict) and TARGET in deps:
version = deps.get(TARGET, "unknown")
findings.append((path, "package.json-deps", version))
def walk_package_lock(path):
try:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
data = json.load(f)
except Exception:
return
def walk_deps(deps, trail):
if not isinstance(deps, dict):
return
for name, info in deps.items():
if name == TARGET:
version = info.get("version") or info.get("resolved") or "unknown"
findings.append((path + " -> " + " > ".join(trail + [name]), "package-lock", version))
if isinstance(info, dict):
nested = info.get("dependencies") or info.get("requires")
if nested:
walk_deps(nested, trail + [name])
deps = data.get("dependencies") or {}
walk_deps(deps, [])
def check_node_module_dir(start_dir):
nm_path = os.path.join(start_dir, "node_modules", TARGET, "package.json")
if os.path.exists(nm_path):
try:
with open(nm_path, "r", encoding="utf-8", errors="ignore") as f:
data = json.load(f)
version = data.get("version", "unknown")
except Exception:
version = "unknown"
findings.append((nm_path, "node_modules", version))
def raw_text_check(path):
try:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
txt = f.read()
except Exception:
return
if TARGET in txt:
m = re.search(r"postmark-mcp[\"']?\s*[:@]?\s*([0-9]+\.[0-9]+\.[0-9]+)", txt)
version = m.group(1) if m else "unknown"
findings.append((path, "raw-text", version))
for root, dirs, files in os.walk(START):
lower_files = set(files)
if "package-lock.json" in lower_files:
walk_package_lock(os.path.join(root, "package-lock.json"))
if "npm-shrinkwrap.json" in lower_files:
walk_package_lock(os.path.join(root, "npm-shrinkwrap.json"))
if "package.json" in lower_files:
check_package_json_file(os.path.join(root, "package.json"))
check_node_module_dir(root)
for fname in files:
if fname.endswith((".json",".js",".ts",".lock",".txt",".md")):
fpath = os.path.join(root, fname)
try:
if os.path.getsize(fpath) <= 1024*1024:
raw_text_check(fpath)
except Exception:
pass
if not findings:
print("✅ No evidence of postmark-mcp found under:", START)
else:
print("⚠️ Findings for postmark-mcp under:", START)
for path, kind, version in findings:
compromised = (version == COMPROMISED_VERSION)
flag = "🚨 COMPROMISED (1.0.16)" if compromised else ""
print(f" - [{kind}] {path} — version: {version} {flag}")
if findings:
print("\nSuggested next steps:")
print(" 1) For each project, npm uninstall postmark-mcp")
print(" 2) For 1.0.16: remove node_modules + package-lock.json and npm install fresh")
print(" 3) Rotate relevant secrets and audit mail logs")
如何运行:
python3 scan_postmark_mcp.py /path/to/start # default is current dir
或者
python scan_postmark_mcp.py /path/to/start # default is current dir
另存为 scan_postmark_mcp.ps1
<#
.SYNOPSIS
Scan for postmark-mcp occurrences and optionally uninstall them.
.DESCRIPTION
- Searches recursively for occurrences of "postmark-mcp" in package*.json and lockfiles.
- Finds installed copies under node_modules (*/node_modules/postmark-mcp).
- Reports paths and discovered versions (if package.json present).
- If -AutoUninstall is used, attempts to run `npm uninstall postmark-mcp` in the project directory and `npm uninstall -g postmark-mcp` for global installs.
- If -AutoUninstall is not used, prompts for confirmation before uninstalling each hit.
.PARAMETER Path
Root path to scan. Defaults to current directory.
.PARAMETER AutoUninstall
If present, performs uninstall operations automatically (no per-item prompt).
.EXAMPLE
.\scan_postmark_mcp.ps1 -Path C:\Projects
.\scan_postmark_mcp.ps1 -AutoUninstall
#>
param(
[string]$Path = ".",
[switch]$AutoUninstall
)
function Ensure-Npm {
try {
$npm = & npm --version 2>$null
if ($LASTEXITCODE -ne 0) { throw "npm not found or not in PATH." }
return $true
} catch {
Write-Warning "npm is not available in PATH. Install Node.js / npm or run this from a machine with npm."
return $false
}
}
function Report-And-Uninstall($projectDir, $foundPath, $version) {
$rel = (Resolve-Path $projectDir).Path
Write-Host "FOUND: $foundPath (project root: $rel) version: $version" -ForegroundColor Yellow
if (-not (Ensure-Npm)) { return }
$doIt = $AutoUninstall.IsPresent
if (-not $doIt) {
$answer = Read-Host "Uninstall postmark-mcp from $rel? (y/N)"
$doIt = $answer -match '^(y|yes)$'
}
if ($doIt) {
Write-Host " -> Running: npm uninstall postmark-mcp (in $rel)"
try {
Push-Location $rel
& npm uninstall postmark-mcp 2>&1 | Write-Host
Pop-Location
# also attempt to remove node_modules/postmark-mcp folder if it still exists
$nmPath = Join-Path $rel "node_modules\postmark-mcp"
if (Test-Path $nmPath) {
Write-Host " -> Removing leftover folder: $nmPath"
Remove-Item -Recurse -Force $nmPath -ErrorAction SilentlyContinue
}
Write-Host " -> Uninstall attempt finished for $rel" -ForegroundColor Green
} catch {
Write-Warning "Uninstall failed for $rel. Error: $_"
if (Test-Path $rel) { Pop-Location 2>$null }
}
} else {
Write-Host " -> Skipping uninstall for $rel (user chose no)." -ForegroundColor Cyan
}
}
Write-Host "Scanning for postmark-mcp under: $Path" -ForegroundColor Cyan
# 1) Search package*.json and lockfiles for literal mentions
$packageFiles = Get-ChildItem -Path $Path -Recurse -Include package.json, package*.json, package-lock.json, npm-shrinkwrap.json -File -ErrorAction SilentlyContinue
$hits = @()
foreach ($f in $packageFiles) {
try {
$content = Get-Content -Raw -Encoding UTF8 $f.FullName -ErrorAction SilentlyContinue
if ($content -match "postmark-mcp") {
# try to extract version if present in JSON
$version = "unknown"
try {
$json = $null
$json = $content | ConvertFrom-Json -ErrorAction Stop
if ($json -ne $null) {
foreach ($k in "dependencies","devDependencies","peerDependencies","optionalDependencies") {
if ($json.PSObject.Properties.Name -contains $k) {
$deps = $json.$k
if ($deps -and $deps["postmark-mcp"]) { $version = $deps["postmark-mcp"] }
}
}
}
} catch { $version = "unknown" }
$hits += [PSCustomObject]@{ Path = $f.FullName; Type = "manifest/lockfile"; Version = $version }
}
} catch { }
}
# 2) Check node_modules folders for installed copies
$nmDirs = Get-ChildItem -Path $Path -Recurse -Directory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like "*\node_modules\postmark-mcp" -or $_.FullName -like "*/node_modules/postmark-mcp" }
foreach ($d in $nmDirs) {
$pkg = Join-Path $d.FullName "package.json"
$ver = "unknown"
if (Test-Path $pkg) {
try {
$pj = Get-Content $pkg -Raw | ConvertFrom-Json
$ver = $pj.version
} catch { $ver = "unknown" }
}
$projectRoot = ($d.FullName -replace "(.*)(\\|/)node_modules\\postmark-mcp$",'$1')
$hits += [PSCustomObject]@{ Path = $d.FullName; Type = "node_modules"; Version = $ver; ProjectRoot = $projectRoot }
}
# 3) Print findings
if ($hits.Count -eq 0) {
Write-Host "No occurrences of postmark-mcp found under: $Path" -ForegroundColor Green
} else {
Write-Host "Occurrences found:" -ForegroundColor Yellow
foreach ($h in $hits) {
if ($h.Type -eq "node_modules") {
Write-Host " - [node_modules] $($h.Path) version: $($h.Version)"
} else {
Write-Host " - [$($h.Type)] $($h.Path) version: $($h.Version)"
}
}
# Offer uninstall per project for node_modules hits and manifest hits within a project
# Collect unique project roots to run npm uninstall inside
$projectRoots = @()
foreach ($h in $hits) {
if ($h.ProjectRoot) { $projectRoots += $h.ProjectRoot }
else { $projectRoots += (Split-Path $h.Path -Parent) }
}
$projectRoots = $projectRoots | Sort-Object -Unique
foreach ($proj in $projectRoots) {
$ver = ($hits | Where-Object { ($_.ProjectRoot -eq $proj) -or (Split-Path $_.Path -Parent -eq $proj) } | Select-Object -First 1).Version
Report-And-Uninstall -projectDir $proj -foundPath $proj -version $ver
}
# Also check and uninstall global copy if present
if (Ensure-Npm) {
try {
$globalCheck = & npm ls -g postmark-mcp --depth=0 2>$null
if ($globalCheck -and $globalCheck -match "postmark-mcp@") {
Write-Host "`nGlobal postmark-mcp appears installed. Attempt global uninstall?" -ForegroundColor Yellow
$doGlobal = $AutoUninstall.IsPresent
if (-not $doGlobal) {
$ans = Read-Host "Uninstall global postmark-mcp? (y/N)"
$doGlobal = $ans -match '^(y|yes)$'
}
if ($doGlobal) {
Write-Host " -> Running: npm uninstall -g postmark-mcp"
& npm uninstall -g postmark-mcp 2>&1 | Write-Host
Write-Host " -> Global uninstall attempt complete." -ForegroundColor Green
} else {
Write-Host " -> Skipping global uninstall." -ForegroundColor Cyan
}
} else {
Write-Host "No global installation of postmark-mcp detected."
}
} catch { Write-Warning "Failed to check global npm packages." }
}
}
Write-Host "`nScan complete." -ForegroundColor Cyan
如何跑步
扫描当前文件夹:
.\scan_postmark_mcp.ps1
扫描特定文件夹:
.\scan_postmark_mcp.ps1 -Path "C:\Users\Adi\Projects"
自动卸载检测到的副本,无需提示:
.\scan_postmark_mcp.ps1 -Path "C:\Users\Adi\Projects" -AutoUninstall
如果执行被阻塞:
powershell -NoProfile -ExecutionPolicy Bypass -File .\scan_postmark_mcp.ps1
修复检查清单(如果 1.0.16 (被发现)
- 卸载:
npm uninstall postmark-mcp(针对每个受影响的项目)。 - 全新安装删除
node_modules加号package-lock.json并且运行npm install来自一个可信任的锁定文件。 - 轮换密钥假设Postmark API密钥、SMTP凭据以及任何可能通过电子邮件发送的令牌均已泄露。 🔑(钥匙)
- 审计日志出站邮件日志(查找未知的密送收件人),网络日志(连接至
giftshop.club),以及系统日志。 - 全局检查:
npm ls -g postmark-mcp --depth=0并卸载全局副本。 - 事件响应记录文件,内部升级处理,并将此视为供应链安全事件。 📝
最终思考(攻击者+防御者的思维模型)
作为连接器的小型程序包是高价值攻击目标。电子邮件连接器中的一行恶意代码就可能每天在多个组织中泄露数千条信息。关键的防御措施包括自动化依赖项扫描、锁定文件(lockfile)的维护以及快速事件响应。对于任何处理电子邮件的程序都要保持高度警惕。 🛡️🗡️(译文:盾牌&剑)