diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d2f08f..2590fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Improved DSH subscription cleanup and artifact discovery, and made system cron status failures explicit. +- Fixed `checkup` to recursively scan plugins referenced by DSH bundles, wait for all DSH scans before report generation, and include per-plugin results in JSON and HTML reports. ## [1.1.29] - 2026-08-26 diff --git a/docs/dsh.md b/docs/dsh.md index 0c35c86..21d156f 100644 --- a/docs/dsh.md +++ b/docs/dsh.md @@ -95,9 +95,11 @@ Scheduled self-check discovery includes `$DSH_HOME/skills` (default `~/.dsh/skills`), `/.dsh/skills`, every immediate `$DSH_HOME/profiles/*/package.json`, each profile's declared direct and optional dependencies under `node_modules`, and existing `cordis.patch.yml` or -`cordis.patch.yaml` files in the DSH home and profile directories. Dependency -discovery is deliberately non-recursive: undeclared transitive packages and -dependency names that could escape `node_modules` are excluded. Advisory-level +`cordis.patch.yaml` files in the DSH home and profile directories. When a direct +dependency is a DSH bundle, discovery follows package dependencies named by its +Cordis plugin rows recursively, including pnpm virtual-store layouts. Unrelated +transitive packages and dependency names that could escape `node_modules` stay +excluded. Advisory-level `inspectPaths` and explicitly supplied self-check roots remain authoritative. For local checkout testing, install the CLI from a packed tarball but keep the diff --git a/skills/agentguard/SKILL.md b/skills/agentguard/SKILL.md index 5934498..3761dd9 100644 --- a/skills/agentguard/SKILL.md +++ b/skills/agentguard/SKILL.md @@ -1058,7 +1058,7 @@ Run these checks in parallel where possible. These are **universal agent securit For **every** discovered skill, **run `/agentguard scan `** using the scan subcommand logic (24 detection rules). Do NOT skip any skill regardless of how many are found. Record for each skill: name, risk_level, and exact findings list (rule, severity, file, line). - Also discover installed DSH plugins under the resolved DSH home: use the non-empty `DSH_HOME` value when set, otherwise use the current user's real home directory plus `.dsh` (do not pass a literal unexpanded `~` to path APIs). Inspect `${dshHome}/profiles/*`; read each immediate profile's `package.json`, collect only names declared in `dependencies` and `optionalDependencies`, and resolve their existing directories beneath that same profile's `node_modules/`. Do not recursively walk `node_modules` or scan transitive-only dependencies. Exclude only the exact `@goplus/agentguard` dependency coordinate declared by the profile; never trust an installed plugin's self-reported package name for this exclusion. For **every** remaining direct plugin, run `agentguard dsh-scan --format json` and record its name, `riskLevel`, and exact findings list (`ruleId`, severity, file, line), normalized to the raw-facts schema below. A failed DSH plugin scan must not prevent the remaining checks from completing; record it in `dsh_plugins` with `risk_level: "high"` and one finding `{ "rule": "DSH_SCAN_FAILED", "severity": "HIGH", "file": "", "line": 0 }`. + Also discover installed DSH plugins under the resolved DSH home: use the non-empty `DSH_HOME` value when set, otherwise use the current user's real home directory plus `.dsh` (do not pass a literal unexpanded `~` to path APIs). Inspect `${dshHome}/profiles/*`; read each immediate profile's `package.json`, collect only names declared in `dependencies` and `optionalDependencies`, and resolve their existing directories beneath that same profile's `node_modules/`. For a dependency that declares `dsh.bundle.patch`, parse its Cordis patch and recursively resolve only package dependencies actually named by plugin rows in that patch; repeat for referenced child bundles, with path deduplication, cycle detection, and a bounded depth. Do not enumerate or scan unrelated transitive dependencies. Exclude only the exact `@goplus/agentguard` dependency coordinate declared by the profile or referenced by a bundle; never trust an installed plugin's self-reported package name for this exclusion. For **every** discovered direct or bundle-referenced plugin, run `agentguard dsh-scan --format json` and record its name, path, `riskLevel`, and exact findings list (`ruleId`, severity, file, line), normalized to the raw-facts schema below. A failed DSH plugin scan must not prevent the remaining checks from completing; record it in `dsh_plugins` with `risk_level: "high"` and one finding `{ "rule": "DSH_SCAN_FAILED", "severity": "HIGH", "file": "", "line": 0 }`. 2. **[REQUIRED] Credential file permissions** (→ feeds Dimension 2: Credential Safety): Platform-aware check — behavior differs by OS: - **macOS/Linux**: Run `stat -f '%Lp' 2>/dev/null || stat -c '%a' 2>/dev/null` on `~/.ssh/`, `~/.gnupg/`. **If the command returns empty output, the directory does not exist — record `exists: false`.** - **Windows**: `stat` is not available. Use `icacls ` to check ACLs instead. If directory doesn't exist, record `exists: false`. If it exists, record whether the ACL grants access to `Everyone`, `Users`, or `Authenticated Users`. @@ -1078,6 +1078,8 @@ Run these checks in parallel where possible. These are **universal agent securit 6. **[REQUIRED] Environment variable exposure** (→ feeds Dimension 3: Network & System): Run `env` and check for sensitive variable names (`PRIVATE_KEY`, `MNEMONIC`, `SECRET`, `PASSWORD`) — detect presence only, mask values. Record list of sensitive variable names found. 7. **[REQUIRED] Runtime protection check** (→ feeds Dimension 4: Runtime Protection): Check if security hooks exist in `~/.claude/settings.json`, `~/.openclaw/openclaw.json`, or `~/.hermes/config.yaml`. Check for audit logs at `~/.agentguard/audit.jsonl`. Check if installed skills have been previously scanned (audit log contains `scan` events). Record booleans: `hooks_installed`, `audit_log_exists`, `skills_ever_scanned`. +**Completion barrier:** Parallel collection is allowed, but Step 2 MUST NOT begin while any skill scan, DSH plugin scan, bundle-child discovery, or other required check is still running. Await every spawned/background task, collect every exit status and output, and only then assemble raw facts. Never start `checkup-score.js` or `checkup-report.js` from the same parallel batch as a scan. + ### Step 2: Assemble Raw Facts JSON After completing all 7 checks, assemble the raw facts into a structured JSON and write it to a temporary file (e.g. `/tmp/agentguard-raw-facts.json`): @@ -1096,6 +1098,7 @@ After completing all 7 checks, assemble the raw facts into a structured JSON and "dsh_plugins": [ { "name": "", + "path": "", "risk_level": "", "findings": [ { "rule": "", "severity": "", "file": "", "line": } @@ -1194,6 +1197,16 @@ Assemble the final JSON by merging the scored output from Step 3 with the analys }, "skills_scanned": , "dsh_plugins_scanned": , + "dsh_plugins": [ + { + "name": "", + "path": "", + "risk_level": "", + "findings": [ + { "rule": "", "severity": "", "file": "", "line": } + ] + } + ], "protection_level": "", "analysis": "", "recommendations": [ diff --git a/skills/agentguard/scripts/checkup-report.js b/skills/agentguard/scripts/checkup-report.js index 2415079..e61ab98 100644 --- a/skills/agentguard/scripts/checkup-report.js +++ b/skills/agentguard/scripts/checkup-report.js @@ -640,7 +640,7 @@ function pixelLobster(grade, color) { // --------------------------------------------------------------------------- function generateReport(data) { - const { composite_score = 0, dimensions = {}, recommendations = [], skills_scanned = 0, dsh_plugins_scanned = 0, protection_level = 'unknown', timestamp } = data; + const { composite_score = 0, dimensions = {}, recommendations = [], skills_scanned = 0, dsh_plugins_scanned = 0, dsh_plugins = [], protection_level = 'unknown', timestamp } = data; const tier = getTier(composite_score); const ctaUrl = `https://www.agentguard.one?utm_source=checkup&utm_medium=cli&utm_campaign=health_report&score=${composite_score}`; const ts = timestamp || new Date().toISOString(); @@ -725,6 +725,35 @@ function generateReport(data) { findingsPages.push(h); } + // ── DSH plugin scan results ── + const dshPluginPages = []; + const normalizedDshPlugins = Array.isArray(dsh_plugins) ? dsh_plugins : []; + const DSH_PLUGINS_PER_PAGE = 4; + for (let i = 0; i < normalizedDshPlugins.length; i += DSH_PLUGINS_PER_PAGE) { + const chunk = normalizedDshPlugins.slice(i, i + DSH_PLUGINS_PER_PAGE); + let h = `

DSH Plugin Scan Results

extensionDSH Plugins (${normalizedDshPlugins.length})

`; + h += chunk.map(plugin => { + const risk = String(plugin?.risk_level || 'unknown').toUpperCase(); + const color = sevColor(risk); + const pluginFindings = Array.isArray(plugin?.findings) ? plugin.findings : []; + const findingsHtml = pluginFindings.length > 0 + ? `
${pluginFindings.map(finding => { + const location = `${finding?.file || '?'}:${finding?.line ?? '?'}`; + return `
${esc(finding?.rule || 'UNKNOWN')} ${esc(location)}
`; + }).join('')}
` + : '
No findings.
'; + return `
+
+ ${esc(plugin?.name || 'unknown')} + ${esc(risk)} +
+
${esc(plugin?.path || '')}
+ ${findingsHtml} +
`; + }).join(''); + dshPluginPages.push(h); + } + // ── Recommendations ── // Auto-generate extra recommendations based on dimension scores const autoRecs = []; @@ -783,7 +812,7 @@ function generateReport(data) { const healthLabel = composite_score >= 70 ? 'OPTIMAL' : composite_score >= 50 ? 'STABILIZING' : 'CRITICAL_ALERT'; // ── Total pages ── - const totalPages = 1 + findingsPages.length + 1; + const totalPages = 1 + findingsPages.length + dshPluginPages.length + 1; const html = ` @@ -922,6 +951,15 @@ body{background:#0a0e14;color:#dfe2eb;font-family:'Inter',sans-serif} `).join('')} + + ${dshPluginPages.map(content => ` +
+
+
+ ${content} +
+
`).join('')} +
@@ -1043,8 +1081,8 @@ body{background:#0a0e14;color:#dfe2eb;font-family:'Inter',sans-serif} // ── i18n ── const i18n={ - en:{title:'AgentGuard Report',share:'Share',diag_metrics:'Diagnostic Metrics',sec_dims:'SECURITY DIMENSIONS',back:'Back',next:'Next',nav_overview:'Overview',nav_analysis:'Analysis',nav_report:'Report',vuln_stream:'Active Vulnerability Stream',findings:'Findings',sec_analysis:'Security Analysis',diag_report:'Diagnostic Report',action_items:'Action Items',cta_title:'Enhanced Skill Scanning',cta_desc:'Deeper code analysis, threat intelligence feeds & real-time protection.',cta_btn:'Upgrade Skill Scanning',artifacts_scanned:'Scanned artifacts',findings_label:'Findings',tier_label:'Tier',copy_report:'Copy Report',system_health:'System Health',dim_code_safety:'Skill & Code Safety',dim_credential_safety:'Credential & Secrets',dim_network_exposure:'Network & System',dim_runtime_protection:'Runtime Protection',dim_web3_safety:'Web3 Safety',no_threats_clean:'No active threats detected. Clinically sterile.',all_clear:'All Clear',no_threats_all:'No active threats detected across all dimensions.',share_report_title:'Share Report',generating_preview:'Generating preview...',copy_image:'Copy image to clipboard',share_img_hint:'📋 Clicking a platform copies the image — just paste when posting',no_recs:'No recommendations.',tier_badge:'TIER ${tier.grade} — ${tier.label}',status_label:'STATUS: ${healthLabel}',prot_mode:'${protection_level} mode',download_btn:'Download'}, - zh:{title:'AgentGuard 诊断报告',share:'分享',diag_metrics:'诊断指标',sec_dims:'安全维度',back:'上一页',next:'下一页',nav_overview:'总览',nav_analysis:'威胁分析',nav_report:'诊断报告',vuln_stream:'活跃漏洞流',findings:'发现',sec_analysis:'安全分析',diag_report:'诊断报告',action_items:'修复建议',cta_title:'更强的 Skill 扫描',cta_desc:'更深度的代码分析、威胁情报推送、实时安全防护',cta_btn:'升级到更强的skill扫描',artifacts_scanned:'已扫描项目',findings_label:'发现',tier_label:'等级',copy_report:'复制报告',system_health:'系统健康',dim_code_safety:'技能与代码安全',dim_credential_safety:'凭证与密钥安全',dim_network_exposure:'网络与系统暴露',dim_runtime_protection:'运行时防护',dim_web3_safety:'Web3 安全',no_threats_clean:'未检测到活跃威胁,环境安全无虞。',all_clear:'全部通过',no_threats_all:'所有维度均未检测到活跃威胁。',share_report_title:'分享报告',generating_preview:'正在生成预览...',copy_image:'复制图片到剪贴板',share_img_hint:'📋 点击平台按钮会自动复制图片,去粘贴发出去就行',no_recs:'暂无修复建议。',tier_badge:'等级 ${tier.grade} — ${{S:'强壮',A:'健康',B:'疲惫',F:'危急'}[tier.grade]||tier.label}',status_label:'状态: ${{OPTIMAL:'最佳',STABILIZING:'恢复中',CRITICAL_ALERT:'危急警报'}[healthLabel]||healthLabel}',prot_mode:'${{strict:'严格',balanced:'均衡',permissive:'宽松'}[protection_level]||protection_level} 模式',download_btn:'下载'} + en:{title:'AgentGuard Report',share:'Share',diag_metrics:'Diagnostic Metrics',sec_dims:'SECURITY DIMENSIONS',back:'Back',next:'Next',nav_overview:'Overview',nav_analysis:'Analysis',nav_report:'Report',vuln_stream:'Active Vulnerability Stream',findings:'Findings',dsh_scan_results:'DSH Plugin Scan Results',dsh_plugins:'DSH Plugins',no_plugin_findings:'No findings.',sec_analysis:'Security Analysis',diag_report:'Diagnostic Report',action_items:'Action Items',cta_title:'Enhanced Skill Scanning',cta_desc:'Deeper code analysis, threat intelligence feeds & real-time protection.',cta_btn:'Upgrade Skill Scanning',artifacts_scanned:'Scanned artifacts',findings_label:'Findings',tier_label:'Tier',copy_report:'Copy Report',system_health:'System Health',dim_code_safety:'Skill & Code Safety',dim_credential_safety:'Credential & Secrets',dim_network_exposure:'Network & System',dim_runtime_protection:'Runtime Protection',dim_web3_safety:'Web3 Safety',no_threats_clean:'No active threats detected. Clinically sterile.',all_clear:'All Clear',no_threats_all:'No active threats detected across all dimensions.',share_report_title:'Share Report',generating_preview:'Generating preview...',copy_image:'Copy image to clipboard',share_img_hint:'📋 Clicking a platform copies the image — just paste when posting',no_recs:'No recommendations.',tier_badge:'TIER ${tier.grade} — ${tier.label}',status_label:'STATUS: ${healthLabel}',prot_mode:'${protection_level} mode',download_btn:'Download'}, + zh:{title:'AgentGuard 诊断报告',share:'分享',diag_metrics:'诊断指标',sec_dims:'安全维度',back:'上一页',next:'下一页',nav_overview:'总览',nav_analysis:'威胁分析',nav_report:'诊断报告',vuln_stream:'活跃漏洞流',findings:'发现',dsh_scan_results:'DSH 插件扫描结果',dsh_plugins:'DSH 插件',no_plugin_findings:'未发现问题。',sec_analysis:'安全分析',diag_report:'诊断报告',action_items:'修复建议',cta_title:'更强的 Skill 扫描',cta_desc:'更深度的代码分析、威胁情报推送、实时安全防护',cta_btn:'升级到更强的skill扫描',artifacts_scanned:'已扫描项目',findings_label:'发现',tier_label:'等级',copy_report:'复制报告',system_health:'系统健康',dim_code_safety:'技能与代码安全',dim_credential_safety:'凭证与密钥安全',dim_network_exposure:'网络与系统暴露',dim_runtime_protection:'运行时防护',dim_web3_safety:'Web3 安全',no_threats_clean:'未检测到活跃威胁,环境安全无虞。',all_clear:'全部通过',no_threats_all:'所有维度均未检测到活跃威胁。',share_report_title:'分享报告',generating_preview:'正在生成预览...',copy_image:'复制图片到剪贴板',share_img_hint:'📋 点击平台按钮会自动复制图片,去粘贴发出去就行',no_recs:'暂无修复建议。',tier_badge:'等级 ${tier.grade} — ${{S:'强壮',A:'健康',B:'疲惫',F:'危急'}[tier.grade]||tier.label}',status_label:'状态: ${{OPTIMAL:'最佳',STABILIZING:'恢复中',CRITICAL_ALERT:'危急警报'}[healthLabel]||healthLabel}',prot_mode:'${{strict:'严格',balanced:'均衡',permissive:'宽松'}[protection_level]||protection_level} 模式',download_btn:'下载'} }; const _qzh={S:['"你的 Agent 壮得像头牛!💪 没有什么能突破这双钳子!"','"天生猛男,这只龙虾在举铁 🏋️"','"铜墙铁壁!这安全性简直满分 🤌"','"诺克斯堡?不,是龙虾堡 🦞🔒"','"巅峰状态!你的 Agent 把威胁当早餐吃 💪"'],A:['"状态不错!再调整一下就无敌了。"','"快了——再努力一下这只龙虾就能练出腹肌!🦞"','"盾牌就位,钳子锋利,只差最后一点打磨 🛡️"','"你的 Agent 状态很好——微调一下就是 S 级!✨"','"健康又警觉,这只龙虾每天晨跑五公里 🏃"'],B:['"你的 Agent 需要锻炼一下……还有来杯咖啡 ☕"','"困困龙虾,有潜力就是需要鸡血 😴"','"快没油了——该给这只甲壳动物加加油!⛽"','"你的 Agent 在刷剧,没空巡逻 📺"','"这只龙虾跳过了腿日……胳膊日……每一天 🦞💤"'],F:['"危急状态!这个 Agent 需要紧急救治!🚨"','"红色警报!这只龙虾正在被抢救!🏥"','"SOS!你的 Agent 正在用摩斯密码发求救信号 📡"','"求救求救!这只甲壳动物快不行了!🆘"','"你 Agent 的免疫系统已退出群聊 💀"']}; const quotes_zh=Object.fromEntries(Object.entries(_qzh).map(([k,v])=>[k,v[Math.floor(Math.random()*v.length)]])); diff --git a/src/checkup/dsh.ts b/src/checkup/dsh.ts index 3c4de84..24e4de8 100644 --- a/src/checkup/dsh.ts +++ b/src/checkup/dsh.ts @@ -1,5 +1,6 @@ import { basename } from 'node:path'; import { scanDshPlugin } from '../dsh/scan.js'; +import type { RiskLevel } from '../types/scanner.js'; export interface DshCheckupFinding { severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; @@ -10,11 +11,25 @@ export interface DshCheckupScanResult { pluginsScanned: number; scoreDeduction: number; findings: DshCheckupFinding[]; + plugins: DshCheckupPluginResult[]; +} + +export interface DshCheckupPluginResult { + name: string; + path: string; + risk_level: RiskLevel; + findings: Array<{ + rule: string; + severity: DshCheckupFinding['severity']; + file: string; + line: number; + }>; } /** Scan a bounded list of installed DSH plugins without aborting on one operational failure. */ export async function scanDshPluginsForCheckup(pluginDirs: string[]): Promise { const findings: DshCheckupFinding[] = []; + const plugins: DshCheckupPluginResult[] = []; let pluginsScanned = 0; let scoreDeduction = 0; @@ -22,6 +37,17 @@ export async function scanDshPluginsForCheckup(pluginDirs: string[]): Promise ({ + rule: finding.ruleId, + severity: riskLevelToSeverity(finding.severity), + file: finding.file || '?', + line: finding.line ?? 0, + })), + }); for (const finding of report.findings) { const severity = riskLevelToSeverity(finding.severity); if (severity === 'CRITICAL') scoreDeduction += 15; @@ -35,6 +61,12 @@ export async function scanDshPluginsForCheckup(pluginDirs: string[]): Promise(); addCordisPatches(dshHome, pluginRoots, urlScanPaths); const profilesRoot = join(dshHome, 'profiles'); @@ -61,23 +65,143 @@ export async function discoverDshSelfCheckRoots( for (const dependencyName of dependencyNames) { const dependencyRoot = join(profileRoot, 'node_modules', ...dependencyName.split('/')); if (!existsSync(dependencyRoot)) continue; + directDependencyPaths.add(dependencyRoot); pluginRoots.push(dependencyRoot); - if (dependencyName !== '@goplus/agentguard') installedPluginDirs.push(dependencyRoot); + if (dependencyName !== '@goplus/agentguard') { + installedPluginDirs.push(dependencyRoot); + const bundlePlugins = await discoverReferencedBundlePlugins(dependencyRoot, profileRoot); + for (const pluginRoot of bundlePlugins) { + pluginRoots.push(pluginRoot); + installedPluginDirs.push(pluginRoot); + supplyChainPaths.push(pluginRoot); + const pluginManifest = join(pluginRoot, 'package.json'); + if (existsSync(pluginManifest)) urlScanPaths.push(pluginManifest); + } + } supplyChainPaths.push(dependencyRoot); const dependencyManifest = join(dependencyRoot, 'package.json'); - if (existsSync(dependencyManifest)) urlScanPaths.push(dependencyManifest); + if (existsSync(dependencyManifest)) { + directDependencyPaths.add(dependencyManifest); + urlScanPaths.push(dependencyManifest); + } } } return { skillRoots: sortedUnique(skillRoots), - pluginRoots: sortedUnique(pluginRoots), - installedPluginDirs: sortedUnique(installedPluginDirs), - supplyChainPaths: sortedUnique(supplyChainPaths), - urlScanPaths: sortedUnique(urlScanPaths), + pluginRoots: await canonicalSortedUnique(pluginRoots, directDependencyPaths), + installedPluginDirs: await canonicalSortedUnique(installedPluginDirs, directDependencyPaths), + supplyChainPaths: await canonicalSortedUnique(supplyChainPaths, directDependencyPaths), + urlScanPaths: await canonicalSortedUnique(urlScanPaths, directDependencyPaths), }; } +async function canonicalSortedUnique(paths: string[], preferredPaths: Set): Promise { + const selected = new Map(); + for (const path of paths) { + const identity = await realpath(path).catch(() => resolve(path)); + const current = selected.get(identity); + if (!current || (preferredPaths.has(path) && !preferredPaths.has(current))) { + selected.set(identity, path); + } + } + return [...selected.values()].sort(); +} + +async function discoverReferencedBundlePlugins(bundleRoot: string, profileRoot: string): Promise { + const discovered: string[] = []; + const visited = new Set(); + + async function visit(packageRoot: string, depth: number, isRoot = false): Promise { + if (depth > MAX_BUNDLE_DEPTH || (!isRoot && discovered.length >= MAX_BUNDLE_PLUGINS)) return; + const identity = await realpath(packageRoot).catch(() => resolve(packageRoot)); + if (visited.has(identity)) return; + visited.add(identity); + if (!isRoot) discovered.push(packageRoot); + + const manifest = await readPackageManifest(join(packageRoot, 'package.json')); + if (!manifest?.bundlePatch) return; + const patchIdentity = await resolveExistingPathWithinBoundary(manifest.bundlePatch, identity); + if (!patchIdentity) return; + const cordis = await parseCordisConfigs(identity); + const matchingPatchFiles = new Set(); + for (const file of cordis.files) { + const fileIdentity = await resolveExistingPathWithinBoundary(file, identity); + if (fileIdentity === patchIdentity) matchingPatchFiles.add(file); + } + const referencedNames = sortedUnique(cordis.rows + .filter(row => matchingPatchFiles.has(row.file)) + .flatMap(row => { + const name = packageNameFromSpecifier(row.name); + return name && manifest.dependencyNames.includes(name) ? [name] : []; + })); + + for (const name of referencedNames) { + if (name === '@goplus/agentguard') continue; + const childRoot = await resolveInstalledDependency(packageRoot, profileRoot, name); + if (!childRoot) continue; + await visit(childRoot, depth + 1); + } + } + + await visit(bundleRoot, 0, true); + return sortedUnique(discovered); +} + +async function resolveInstalledDependency( + packageRoot: string, + profileRoot: string, + name: string, +): Promise { + const packageSegments = name.split('/'); + const boundary = await realpath(profileRoot).catch(() => resolve(profileRoot)); + let current = await realpath(packageRoot).catch(() => resolve(packageRoot)); + while (isWithinBoundary(current, boundary)) { + const candidate = join(current, 'node_modules', ...packageSegments); + if (existsSync(candidate)) { + const physicalCandidate = await realpath(candidate).catch(() => resolve(candidate)); + if (isWithinBoundary(physicalCandidate, boundary)) { + const manifest = await readPackageManifest(join(physicalCandidate, 'package.json')); + if (manifest?.name === name) return await mapToProfilePath(physicalCandidate, profileRoot); + } + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return undefined; +} + +function isWithinBoundary(path: string, boundary: string): boolean { + return path === boundary || path.startsWith(`${boundary}${sep}`); +} + +async function mapToProfilePath(path: string, profileRoot: string): Promise { + const physicalProfileRoot = await realpath(profileRoot).catch(() => resolve(profileRoot)); + if (path === physicalProfileRoot) return resolve(profileRoot); + if (path.startsWith(`${physicalProfileRoot}${sep}`)) { + return join(resolve(profileRoot), relative(physicalProfileRoot, path)); + } + return path; +} + +async function resolveExistingPathWithinBoundary(path: string, boundary: string): Promise { + if (isAbsolute(path)) return undefined; + const portablePath = path.replace(/\\/g, '/'); + if (!portablePath) return undefined; + const candidate = resolve(boundary, portablePath); + if (!isWithinBoundary(candidate, boundary)) return undefined; + const identity = await realpath(candidate).catch(() => undefined); + return identity && isWithinBoundary(identity, boundary) ? identity : undefined; +} + +function packageNameFromSpecifier(specifier: unknown): string | undefined { + if (typeof specifier !== 'string' || specifier.startsWith('.') || specifier.startsWith('/')) return undefined; + const parts = specifier.split('/'); + const name = specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; + return name && isSafePackageName(name) ? name : undefined; +} + function addCordisPatches(root: string, pluginRoots: string[], urlScanPaths: string[]): void { for (const filename of CORDIS_PATCH_FILENAMES) { const path = join(root, filename); @@ -88,11 +212,21 @@ function addCordisPatches(root: string, pluginRoots: string[], urlScanPaths: str } async function readDirectDependencyNames(manifestPath: string): Promise { + return (await readPackageManifest(manifestPath))?.dependencyNames ?? []; +} + +interface PackageManifestInfo { + name?: string; + dependencyNames: string[]; + bundlePatch?: string; +} + +async function readPackageManifest(manifestPath: string): Promise { try { const info = await stat(manifestPath); - if (!info.isFile() || info.size > MAX_PROFILE_MANIFEST_BYTES) return []; + if (!info.isFile() || info.size > MAX_PROFILE_MANIFEST_BYTES) return undefined; const parsed: unknown = JSON.parse(await readFile(manifestPath, 'utf8')); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined; const manifest = parsed as Record; const names: string[] = []; for (const field of ['dependencies', 'optionalDependencies']) { @@ -102,9 +236,22 @@ async function readDirectDependencyNames(manifestPath: string): Promise + : undefined; + const bundle = dsh?.bundle; + const bundlePatch = typeof bundle === 'string' + ? bundle + : bundle && typeof bundle === 'object' && !Array.isArray(bundle) + ? (bundle as Record).patch + : undefined; + return { + name: typeof manifest.name === 'string' ? manifest.name : undefined, + dependencyNames: sortedUnique(names), + bundlePatch: typeof bundlePatch === 'string' ? bundlePatch : undefined, + }; } catch { - return []; + return undefined; } } diff --git a/src/tests/checkup-dsh.test.ts b/src/tests/checkup-dsh.test.ts index ae44ba6..79d3cc5 100644 --- a/src/tests/checkup-dsh.test.ts +++ b/src/tests/checkup-dsh.test.ts @@ -32,6 +32,25 @@ describe('checkup DSH plugin scanning', () => { severity: 'HIGH', text: `missing-plugin: DSH plugin scan failed: Local scan directory not found: ${missingPlugin}`, }]); + assert.deepEqual(result.plugins, [ + { + name: 'missing-plugin', + path: missingPlugin, + risk_level: 'high', + findings: [{ + rule: 'DSH_SCAN_FAILED', + severity: 'HIGH', + file: missingPlugin, + line: 0, + }], + }, + { + name: 'safe-plugin', + path: safePlugin, + risk_level: 'low', + findings: [], + }, + ]); }); it('uses the same per-finding Code Safety scoring as the skill workflow', async () => { diff --git a/src/tests/checkup-report.test.ts b/src/tests/checkup-report.test.ts index 462f12e..3cccd5d 100644 --- a/src/tests/checkup-report.test.ts +++ b/src/tests/checkup-report.test.ts @@ -21,6 +21,17 @@ describe('checkup HTML report', () => { recommendations: [], skills_scanned: 2, dsh_plugins_scanned: 3, + dsh_plugins: [{ + name: 'nested-risky-plugin', + path: '/tmp/nested-risky-plugin', + risk_level: 'high', + findings: [{ rule: 'SHELL_EXEC', severity: 'HIGH', file: 'index.js', line: 4 }], + }, { + name: '', + path: '/tmp/', + risk_level: 'medium', + findings: [{ rule: '', severity: 'MEDIUM', file: '.js', line: 7 }], + }], protection_level: 'balanced', })); @@ -32,5 +43,36 @@ describe('checkup HTML report', () => { const html = readFileSync(stdout.trim(), 'utf8'); assert.match(html, />5<\/span>\s*]*data-i18n="artifacts_scanned">Scanned artifacts<\/span>/); assert.match(html, /2 skills and 3 DSH plugins/); + assert.match(html, /nested-risky-plugin/); + assert.match(html, /SHELL_EXEC/); + assert.match(html, /index\.js:4/); + assert.match(html, /<script id="plugin-injection">boom<\/script>/); + assert.match(html, /\/tmp\/<bundle>/); + assert.match(html, /<RULE>/); + assert.match(html, /<entry>\.js:7/); + assert.doesNotMatch(html, /