@shnitzel/plugscout 0.3.25 → 0.3.27

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.
@@ -19,6 +19,7 @@ import { detectProjectSignals } from '../../recommendation/project-analysis.js';
19
19
  import { recommend } from '../../recommendation/engine.js';
20
20
  import { loadRequirementsProfile } from '../../recommendation/requirements.js';
21
21
  import { assessRisk, buildAssessment } from '../../security/assessment.js';
22
+ import { runLiveChecks, formatLiveChecks } from '../../security/live-check.js';
22
23
  import { applyQuarantineFromReport, verifyWhitelist } from '../../security/whitelist.js';
23
24
  import { runDoctorChecks } from './doctor.js';
24
25
  import { renderCsv } from './formatters/csv.js';
@@ -486,6 +487,7 @@ async function handleShow(args) {
486
487
  if (!id) {
487
488
  throw new Error('Usage: show --id <catalog-id>');
488
489
  }
490
+ const noLive = hasFlag(args, '--no-live');
489
491
  const [item, whitelist, quarantine, insights] = await Promise.all([
490
492
  loadCatalogItemById(id),
491
493
  loadWhitelist(),
@@ -522,6 +524,15 @@ async function handleShow(args) {
522
524
  console.log(` ${link.label}: ${link.url}`);
523
525
  }
524
526
  }
527
+ if (!noLive) {
528
+ process.stdout.write('\x1b[90mRunning live checks…\x1b[0m\r');
529
+ const liveResults = await runLiveChecks(item);
530
+ process.stdout.write(' \r');
531
+ const liveOutput = formatLiveChecks(liveResults);
532
+ if (liveOutput) {
533
+ console.log(liveOutput);
534
+ }
535
+ }
525
536
  }
526
537
  async function handleSearch(args) {
527
538
  const query = args[0]?.trim();
@@ -806,6 +817,7 @@ async function handleAssess(args) {
806
817
  if (!id) {
807
818
  throw new Error('Missing --id for assess');
808
819
  }
820
+ const noLive = hasFlag(args, '--no-live');
809
821
  const found = await loadCatalogItemById(id);
810
822
  if (!found) {
811
823
  throw new Error(await buildCatalogItemNotFoundMessage(id));
@@ -821,6 +833,15 @@ async function handleAssess(args) {
821
833
  console.log(` ${link.label}: ${link.url}`);
822
834
  }
823
835
  }
836
+ if (!noLive) {
837
+ process.stdout.write('\x1b[90mRunning live checks…\x1b[0m\r');
838
+ const liveResults = await runLiveChecks(found);
839
+ process.stdout.write(' \r');
840
+ const liveOutput = formatLiveChecks(liveResults);
841
+ if (liveOutput) {
842
+ console.log(liveOutput);
843
+ }
844
+ }
824
845
  }
825
846
  async function handleInstall(args) {
826
847
  const id = readFlag(args, '--id');
@@ -0,0 +1,333 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { getStatePath } from '../lib/paths.js';
4
+ const TTL_MS = {
5
+ 'osv': 6 * 60 * 60 * 1000,
6
+ 'npm-registry': 6 * 60 * 60 * 1000,
7
+ 'vscode-marketplace': 6 * 60 * 60 * 1000,
8
+ 'github': 60 * 60 * 1000,
9
+ 'url-health': 60 * 60 * 1000,
10
+ };
11
+ const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000;
12
+ async function readCache(itemId) {
13
+ const p = getStatePath('data', 'live-checks', `${itemId.replace(/[^a-zA-Z0-9-_.]/g, '_')}.json`);
14
+ try {
15
+ const entry = await fs.readJson(p);
16
+ const now = Date.now();
17
+ // Each result may have a different TTL — use the shortest applicable
18
+ const allFresh = entry.results.every(r => {
19
+ const ttl = TTL_MS[r.source] ?? DEFAULT_TTL_MS;
20
+ return now - new Date(r.checkedAt).getTime() < ttl;
21
+ });
22
+ return allFresh ? entry.results : null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ async function writeCache(itemId, results) {
29
+ const p = getStatePath('data', 'live-checks', `${itemId.replace(/[^a-zA-Z0-9-_.]/g, '_')}.json`);
30
+ const entry = { results, cachedAt: new Date().toISOString() };
31
+ try {
32
+ await fs.ensureFile(p);
33
+ await fs.writeJson(p, entry, { spaces: 2 });
34
+ }
35
+ catch {
36
+ // cache write failure is non-fatal
37
+ }
38
+ }
39
+ async function fetchWithTimeout(url, options, timeoutMs) {
40
+ const controller = new AbortController();
41
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
42
+ try {
43
+ return await fetch(url, { ...options, signal: controller.signal });
44
+ }
45
+ finally {
46
+ clearTimeout(timer);
47
+ }
48
+ }
49
+ async function checkOsv(pkg) {
50
+ const checkedAt = new Date().toISOString();
51
+ try {
52
+ const res = await fetchWithTimeout('https://api.osv.dev/v1/query', {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json' },
55
+ body: JSON.stringify({ package: { name: pkg, ecosystem: 'npm' } }),
56
+ }, 6000);
57
+ if (!res.ok) {
58
+ return { source: 'osv', label: 'OSV vulnerability database', status: 'error', findings: [], checkedAt };
59
+ }
60
+ const data = await res.json();
61
+ const vulns = data.vulns ?? [];
62
+ if (vulns.length === 0) {
63
+ return {
64
+ source: 'osv', label: 'OSV vulnerability database', status: 'clean',
65
+ findings: [{ severity: 'info', text: 'No known vulnerabilities' }],
66
+ checkedAt,
67
+ };
68
+ }
69
+ return {
70
+ source: 'osv', label: 'OSV vulnerability database', status: 'flagged',
71
+ findings: vulns.slice(0, 5).map(v => ({
72
+ severity: 'critical',
73
+ text: `${v.id}${v.summary ? ': ' + v.summary.slice(0, 100) : ''}`,
74
+ })),
75
+ checkedAt,
76
+ };
77
+ }
78
+ catch {
79
+ return { source: 'osv', label: 'OSV vulnerability database', status: 'error', findings: [{ severity: 'info', text: 'Check timed out' }], checkedAt };
80
+ }
81
+ }
82
+ async function checkNpmRegistry(pkg) {
83
+ const checkedAt = new Date().toISOString();
84
+ try {
85
+ const res = await fetchWithTimeout(`https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`, { headers: { 'User-Agent': 'plugscout/1.0' } }, 6000);
86
+ if (!res.ok) {
87
+ return { source: 'npm-registry', label: 'npm registry', status: 'unavailable', findings: [{ severity: 'warn', text: `Package not found (HTTP ${res.status})` }], checkedAt };
88
+ }
89
+ const data = await res.json();
90
+ if (data.deprecated) {
91
+ const msg = typeof data.deprecated === 'string' ? data.deprecated : 'package is deprecated';
92
+ return {
93
+ source: 'npm-registry', label: 'npm registry', status: 'flagged',
94
+ findings: [{ severity: 'warn', text: `Deprecated: ${msg}` }],
95
+ checkedAt,
96
+ };
97
+ }
98
+ return {
99
+ source: 'npm-registry', label: 'npm registry', status: 'clean',
100
+ findings: [{ severity: 'info', text: `Latest: v${data.version ?? 'unknown'} — not deprecated` }],
101
+ checkedAt,
102
+ };
103
+ }
104
+ catch {
105
+ return { source: 'npm-registry', label: 'npm registry', status: 'error', findings: [{ severity: 'info', text: 'Check timed out' }], checkedAt };
106
+ }
107
+ }
108
+ async function checkVscodeMarketplace(vsixId) {
109
+ const checkedAt = new Date().toISOString();
110
+ const error = (status, text) => ({
111
+ result: { source: 'vscode-marketplace', label: 'VS Code Marketplace', status, findings: text ? [{ severity: 'info', text }] : [], checkedAt },
112
+ });
113
+ try {
114
+ const res = await fetchWithTimeout('https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery', {
115
+ method: 'POST',
116
+ headers: {
117
+ 'Content-Type': 'application/json',
118
+ 'Accept': 'application/json;api-version=7.2-preview.1',
119
+ 'User-Agent': 'plugscout/1.0',
120
+ },
121
+ body: JSON.stringify({
122
+ filters: [{ criteria: [{ filterType: 7, value: vsixId }] }],
123
+ // statistics (0x200) + assetUri (0x100) + excludeNonValidated (0x20) + versionProperties (0x10)
124
+ flags: 0x200 | 0x100 | 0x20 | 0x10,
125
+ }),
126
+ }, 8000);
127
+ if (!res.ok)
128
+ return error('error');
129
+ const data = await res.json();
130
+ const ext = data.results?.[0]?.extensions?.[0];
131
+ if (!ext) {
132
+ return {
133
+ result: {
134
+ source: 'vscode-marketplace', label: 'VS Code Marketplace', status: 'unavailable',
135
+ findings: [{ severity: 'warn', text: 'Extension not found in marketplace — may have been removed' }],
136
+ checkedAt,
137
+ },
138
+ };
139
+ }
140
+ // Extract GitHub repository URL from version properties
141
+ const props = ext.versions?.[0]?.properties ?? [];
142
+ const repoProp = props.find(p => p.key === 'Microsoft.VisualStudio.Services.Links.Source' ||
143
+ p.key === 'Microsoft.VisualStudio.Services.Links.GitHub');
144
+ const repositoryUrl = repoProp?.value?.includes('github.com') ? repoProp.value : undefined;
145
+ const findings = [];
146
+ const verified = ext.publisher.isDomainVerified ?? false;
147
+ const latest = ext.versions?.[0];
148
+ const installs = ext.statistics?.find(s => s.statisticName === 'install')?.value ?? 0;
149
+ findings.push({
150
+ severity: verified ? 'info' : 'warn',
151
+ text: verified ? 'Publisher domain verified' : 'Publisher not domain-verified',
152
+ });
153
+ if (latest) {
154
+ findings.push({ severity: 'info', text: `v${latest.version} — updated ${latest.lastUpdated.slice(0, 10)}` });
155
+ }
156
+ if (installs > 0) {
157
+ findings.push({ severity: 'info', text: `${installs.toLocaleString()} installs` });
158
+ }
159
+ return {
160
+ result: {
161
+ source: 'vscode-marketplace', label: 'VS Code Marketplace',
162
+ status: verified ? 'clean' : 'flagged',
163
+ findings, checkedAt,
164
+ },
165
+ repositoryUrl,
166
+ };
167
+ }
168
+ catch {
169
+ return error('error', 'Check timed out');
170
+ }
171
+ }
172
+ function extractGithubOwnerRepo(url) {
173
+ const m = url.match(/github\.com\/([^/]+\/[^/]+?)(?:\/|$|#|\?|\.git)/);
174
+ return m ? m[1] : null;
175
+ }
176
+ async function checkGithubRepo(ownerRepo) {
177
+ const checkedAt = new Date().toISOString();
178
+ try {
179
+ const res = await fetchWithTimeout(`https://api.github.com/repos/${ownerRepo}`, { headers: { 'User-Agent': 'plugscout/1.0', 'Accept': 'application/vnd.github.v3+json' } }, 6000);
180
+ if (res.status === 404) {
181
+ return {
182
+ source: 'github', label: 'GitHub repository', status: 'unavailable',
183
+ findings: [{ severity: 'warn', text: 'Repository not found or private' }],
184
+ checkedAt,
185
+ };
186
+ }
187
+ if (res.status === 403) {
188
+ return { source: 'github', label: 'GitHub repository', status: 'error', findings: [{ severity: 'info', text: 'Rate limited — try again later' }], checkedAt };
189
+ }
190
+ if (!res.ok) {
191
+ return { source: 'github', label: 'GitHub repository', status: 'error', findings: [], checkedAt };
192
+ }
193
+ const repo = await res.json();
194
+ const findings = [];
195
+ if (repo.disabled) {
196
+ findings.push({ severity: 'critical', text: 'Repository is disabled' });
197
+ }
198
+ else if (repo.archived) {
199
+ findings.push({ severity: 'warn', text: 'Repository is archived — no longer maintained' });
200
+ }
201
+ else {
202
+ const lastPush = repo.pushed_at ? repo.pushed_at.slice(0, 10) : 'unknown';
203
+ findings.push({ severity: 'info', text: `Active — last push: ${lastPush}` });
204
+ }
205
+ return {
206
+ source: 'github', label: 'GitHub repository',
207
+ status: (repo.archived || repo.disabled) ? 'flagged' : 'clean',
208
+ findings, checkedAt,
209
+ };
210
+ }
211
+ catch {
212
+ return { source: 'github', label: 'GitHub repository', status: 'error', findings: [{ severity: 'info', text: 'Check timed out' }], checkedAt };
213
+ }
214
+ }
215
+ async function checkUrlHealth(url) {
216
+ const checkedAt = new Date().toISOString();
217
+ try {
218
+ const res = await fetchWithTimeout(url, { method: 'HEAD', headers: { 'User-Agent': 'plugscout/1.0' } }, 6000);
219
+ // 405 = HEAD not allowed but server is live
220
+ const reachable = res.status < 400 || res.status === 405;
221
+ return {
222
+ source: 'url-health', label: 'Install URL',
223
+ status: reachable ? 'clean' : 'flagged',
224
+ findings: [{ severity: reachable ? 'info' : 'warn', text: `HTTP ${res.status} — ${reachable ? 'reachable' : 'not reachable'}` }],
225
+ checkedAt,
226
+ };
227
+ }
228
+ catch {
229
+ return {
230
+ source: 'url-health', label: 'Install URL', status: 'flagged',
231
+ findings: [{ severity: 'warn', text: 'URL unreachable' }],
232
+ checkedAt,
233
+ };
234
+ }
235
+ }
236
+ function asMetadata(value) {
237
+ if (!value || typeof value !== 'object' || Array.isArray(value))
238
+ return {};
239
+ return value;
240
+ }
241
+ function findGithubOwnerRepo(item, meta) {
242
+ const candidates = [meta.repositoryUrl, meta.githubUrl, meta.sourceRepo];
243
+ for (const c of candidates) {
244
+ if (typeof c === 'string') {
245
+ if (c.includes('github.com')) {
246
+ const r = extractGithubOwnerRepo(c);
247
+ if (r)
248
+ return r;
249
+ }
250
+ else if (!c.startsWith('http') && c.includes('/')) {
251
+ return c;
252
+ }
253
+ }
254
+ }
255
+ const installUrl = item.install.url;
256
+ if (typeof installUrl === 'string' && installUrl.includes('github.com')) {
257
+ return extractGithubOwnerRepo(installUrl);
258
+ }
259
+ return null;
260
+ }
261
+ export async function runLiveChecks(item, opts = {}) {
262
+ if (!opts.noCache) {
263
+ const cached = await readCache(item.id);
264
+ if (cached)
265
+ return cached;
266
+ }
267
+ const meta = asMetadata(item.metadata);
268
+ const checks = [];
269
+ const npmPkg = typeof meta.npmPackage === 'string' ? meta.npmPackage : null;
270
+ if (npmPkg) {
271
+ checks.push(checkOsv(npmPkg));
272
+ checks.push(checkNpmRegistry(npmPkg));
273
+ }
274
+ // cursor-extension: marketplace first (to extract repo URL), then github check if found
275
+ const cursorMarketplaceResults = [];
276
+ if (item.kind === 'cursor-extension') {
277
+ const vsixId = typeof meta.vsixId === 'string' ? meta.vsixId : null;
278
+ if (vsixId) {
279
+ const mkt = await checkVscodeMarketplace(vsixId);
280
+ cursorMarketplaceResults.push(mkt.result);
281
+ const ghOwnerRepo = mkt.repositoryUrl ? extractGithubOwnerRepo(mkt.repositoryUrl) : findGithubOwnerRepo(item, meta);
282
+ if (ghOwnerRepo) {
283
+ checks.push(checkGithubRepo(ghOwnerRepo));
284
+ }
285
+ }
286
+ }
287
+ else {
288
+ const ghOwnerRepo = findGithubOwnerRepo(item, meta);
289
+ if (ghOwnerRepo) {
290
+ checks.push(checkGithubRepo(ghOwnerRepo));
291
+ }
292
+ }
293
+ if (item.kind === 'claude-plugin' || item.kind === 'claude-connector') {
294
+ const installUrl = item.install.url;
295
+ if (typeof installUrl === 'string' && installUrl.startsWith('https://')) {
296
+ checks.push(checkUrlHealth(installUrl));
297
+ }
298
+ }
299
+ const results = [...cursorMarketplaceResults, ...(checks.length > 0 ? await Promise.all(checks) : [])];
300
+ if (results.length === 0)
301
+ return [];
302
+ await writeCache(item.id, results);
303
+ return results;
304
+ }
305
+ export function formatLiveChecks(results) {
306
+ if (results.length === 0)
307
+ return '';
308
+ const lines = ['\nLive checks:'];
309
+ for (const r of results) {
310
+ const icon = r.status === 'clean' ? '\x1b[32m✓\x1b[0m'
311
+ : r.status === 'flagged' ? '\x1b[31m✗\x1b[0m'
312
+ : r.status === 'unavailable' ? '\x1b[33m?\x1b[0m'
313
+ : '\x1b[90m~\x1b[0m';
314
+ const cached = new Date(r.checkedAt).getTime() < Date.now() - 10000 ? ' \x1b[90m(cached)\x1b[0m' : '';
315
+ lines.push(` ${icon} ${r.label}${cached}`);
316
+ for (const f of r.findings) {
317
+ const fc = f.severity === 'critical' ? '\x1b[31m' : f.severity === 'warn' ? '\x1b[33m' : '\x1b[90m';
318
+ lines.push(` ${fc}${f.text}\x1b[0m`);
319
+ }
320
+ }
321
+ return lines.join('\n');
322
+ }
323
+ export async function clearLiveCheckCache(itemId) {
324
+ if (itemId) {
325
+ const p = getStatePath('data', 'live-checks', `${itemId.replace(/[^a-zA-Z0-9-_.]/g, '_')}.json`);
326
+ await fs.remove(p);
327
+ }
328
+ else {
329
+ await fs.emptyDir(getStatePath('data', 'live-checks'));
330
+ }
331
+ }
332
+ // Suppress unused import warning — path is used implicitly via getStatePath internals
333
+ void path.resolve;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shnitzel/plugscout",
3
- "version": "0.3.25",
3
+ "version": "0.3.27",
4
4
  "description": "Claude plugins + Claude connectors + Copilot extensions + Skills + MCP security intelligence framework",
5
5
  "private": false,
6
6
  "type": "module",