hackmyagent 0.15.4 → 0.15.6

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/dist/cli.js CHANGED
@@ -167,10 +167,11 @@ const RISK_DISPLAY = {
167
167
  const RESET = () => colors.reset;
168
168
  program
169
169
  .command('check')
170
- .description(`Check if a package or skill is safe
170
+ .description(`Check if a package, repo, or skill is safe
171
171
 
172
- Accepts npm packages, local paths, or skill identifiers:
172
+ Accepts npm packages, GitHub repos, local paths, or skill identifiers:
173
173
  • npm package: downloads and runs full security analysis (204 checks + NanoMind)
174
+ • GitHub repo: shallow clones and runs full security analysis
174
175
  • Local path: runs NanoMind semantic analysis
175
176
  • Skill identifier: verifies publisher, permissions, revocation
176
177
 
@@ -180,9 +181,11 @@ Exit code 1 if high/critical risk detected.
180
181
  Examples:
181
182
  $ hackmyagent check express
182
183
  $ hackmyagent check @modelcontextprotocol/server-filesystem
183
- $ hackmyagent check @modelcontextprotocol/server-filesystem --json
184
+ $ hackmyagent check modelcontextprotocol/servers
185
+ $ hackmyagent check https://github.com/punkpeye/awesome-mcp-servers
184
186
  $ hackmyagent check ./my-agent/
185
- $ hackmyagent check @publisher/skill --verbose`)
187
+ $ hackmyagent check @publisher/skill --verbose
188
+ $ hackmyagent check modelcontextprotocol/servers --json`)
186
189
  .argument('<target>', 'npm package name, local path, or skill identifier')
187
190
  .option('-v, --verbose', 'Show detailed verification info')
188
191
  .option('--json', 'Output as JSON (for scripting/CI)')
@@ -241,6 +244,11 @@ Examples:
241
244
  process.exit(1);
242
245
  return;
243
246
  }
247
+ // GitHub repo: clone, run full HMA scan, clean up
248
+ if (looksLikeGitHubRepo(skill)) {
249
+ await checkGitHubRepo(skill, options);
250
+ return;
251
+ }
244
252
  // npm package name: download, run full HMA scan, clean up
245
253
  if (looksLikeNpmPackage(skill)) {
246
254
  await checkNpmPackage(skill, options);
@@ -5757,6 +5765,9 @@ function looksLikeNpmPackage(target) {
5757
5765
  // Local paths
5758
5766
  if (target.startsWith('.') || target.startsWith('/'))
5759
5767
  return false;
5768
+ // GitHub URLs are not npm packages
5769
+ if (looksLikeGitHubRepo(target))
5770
+ return false;
5760
5771
  // Scoped packages are always npm
5761
5772
  if (target.startsWith('@') && target.includes('/'))
5762
5773
  return true;
@@ -5770,6 +5781,52 @@ function looksLikeNpmPackage(target) {
5770
5781
  // npm names are lowercase, may contain hyphens and digits
5771
5782
  return /^[a-z0-9][a-z0-9._-]*$/.test(target);
5772
5783
  }
5784
+ /**
5785
+ * Detect whether a string looks like a GitHub repository.
5786
+ *
5787
+ * Matches:
5788
+ * - Full URLs: https://github.com/org/repo, http://github.com/org/repo
5789
+ * - With .git suffix: https://github.com/org/repo.git
5790
+ * - With subpath: https://github.com/org/repo/tree/main/subdir
5791
+ * - Shorthand: org/repo (exactly one slash, no dots, not a scoped npm package)
5792
+ */
5793
+ function looksLikeGitHubRepo(target) {
5794
+ // Full GitHub URLs
5795
+ if (/^https?:\/\/(www\.)?github\.com\/[^/]+\/[^/]+/.test(target))
5796
+ return true;
5797
+ // Shorthand: org/repo — exactly one slash, no dots, no @, no protocol
5798
+ if (!target.includes(':') && !target.includes('.') && !target.startsWith('@') && !target.startsWith('/')) {
5799
+ const parts = target.split('/');
5800
+ if (parts.length === 2 && parts[0].length > 0 && parts[1].length > 0) {
5801
+ // Both parts must look like GitHub identifiers (alphanumeric, hyphens, underscores)
5802
+ return /^[a-zA-Z0-9_-]+$/.test(parts[0]) && /^[a-zA-Z0-9._-]+$/.test(parts[1]);
5803
+ }
5804
+ }
5805
+ return false;
5806
+ }
5807
+ /**
5808
+ * Parse a GitHub target into org/repo and optional clone URL.
5809
+ * Returns { org, repo, cloneUrl }
5810
+ */
5811
+ function parseGitHubTarget(target) {
5812
+ // Full URL: https://github.com/org/repo[.git][/tree/...]
5813
+ const urlMatch = target.match(/^https?:\/\/(www\.)?github\.com\/([^/]+)\/([^/.]+)/);
5814
+ if (urlMatch) {
5815
+ return {
5816
+ org: urlMatch[2],
5817
+ repo: urlMatch[3],
5818
+ cloneUrl: `https://github.com/${urlMatch[2]}/${urlMatch[3]}.git`,
5819
+ };
5820
+ }
5821
+ // Shorthand: org/repo
5822
+ const parts = target.split('/');
5823
+ const repo = parts[1].replace(/\.git$/, '');
5824
+ return {
5825
+ org: parts[0],
5826
+ repo,
5827
+ cloneUrl: `https://github.com/${parts[0]}/${repo}.git`,
5828
+ };
5829
+ }
5773
5830
  const REGISTRY_URL = 'https://api.oa2a.org';
5774
5831
  const STALE_SCAN_DAYS = 3;
5775
5832
  // ============================================================================
@@ -5946,6 +6003,201 @@ function displayRegistryResult(data) {
5946
6003
  }
5947
6004
  console.log();
5948
6005
  }
6006
+ /**
6007
+ * Search the npm registry for packages similar to the given name.
6008
+ * Returns up to 3 package name suggestions. Fails silently on any error.
6009
+ */
6010
+ async function suggestSimilarPackages(name) {
6011
+ const controller = new AbortController();
6012
+ const timeout = setTimeout(() => controller.abort(), 5000);
6013
+ try {
6014
+ // Build search queries: the name itself, plus the unscoped name for scoped packages
6015
+ const queries = [name];
6016
+ const scopeMatch = name.match(/^@[^/]+\/(.+)$/);
6017
+ if (scopeMatch) {
6018
+ queries.push(scopeMatch[1]);
6019
+ }
6020
+ const seen = new Set();
6021
+ const suggestions = [];
6022
+ for (const query of queries) {
6023
+ if (suggestions.length >= 3)
6024
+ break;
6025
+ const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=5`;
6026
+ const res = await fetch(url, { signal: controller.signal });
6027
+ if (!res.ok)
6028
+ continue;
6029
+ const data = await res.json();
6030
+ if (!data.objects)
6031
+ continue;
6032
+ for (const obj of data.objects) {
6033
+ const pkg = obj.package.name;
6034
+ if (pkg === name || seen.has(pkg))
6035
+ continue;
6036
+ seen.add(pkg);
6037
+ suggestions.push(pkg);
6038
+ if (suggestions.length >= 3)
6039
+ break;
6040
+ }
6041
+ }
6042
+ return suggestions;
6043
+ }
6044
+ finally {
6045
+ clearTimeout(timeout);
6046
+ }
6047
+ }
6048
+ /**
6049
+ * Clone a GitHub repo (shallow), run full HMA secure scan, display results, clean up.
6050
+ * Checks the registry first; only clones if data is missing or stale.
6051
+ */
6052
+ async function checkGitHubRepo(target, options) {
6053
+ const { org, repo, cloneUrl } = parseGitHubTarget(target);
6054
+ const displayName = `${org}/${repo}`;
6055
+ // Step 1: Check registry for existing trust data
6056
+ if (!options.offline) {
6057
+ const registryData = await queryRegistry(displayName);
6058
+ if (registryData?.found && !isScanStale(registryData.lastScannedAt)) {
6059
+ if (options.json) {
6060
+ writeJsonStdout({ ...registryData, source: 'registry' });
6061
+ return;
6062
+ }
6063
+ displayRegistryResult(registryData);
6064
+ return;
6065
+ }
6066
+ if (registryData?.found && registryData.lastScannedAt) {
6067
+ if (!options.json && !globalCiMode) {
6068
+ const days = Math.floor((Date.now() - new Date(registryData.lastScannedAt).getTime()) / (1000 * 60 * 60 * 24));
6069
+ console.error(`\nRegistry data is ${days} day(s) old. Re-scanning...`);
6070
+ }
6071
+ }
6072
+ }
6073
+ // Step 2: Clone and scan
6074
+ const { mkdtemp, rm } = await Promise.resolve().then(() => __importStar(require('node:fs/promises')));
6075
+ const { tmpdir } = await Promise.resolve().then(() => __importStar(require('node:os')));
6076
+ const { join } = await Promise.resolve().then(() => __importStar(require('node:path')));
6077
+ const { execFile } = await Promise.resolve().then(() => __importStar(require('node:child_process')));
6078
+ const { promisify } = await Promise.resolve().then(() => __importStar(require('node:util')));
6079
+ const execAsync = promisify(execFile);
6080
+ if (!options.json && !globalCiMode) {
6081
+ console.error(`Cloning ${displayName} from GitHub...`);
6082
+ }
6083
+ const tempDir = await mkdtemp(join(tmpdir(), 'hma-check-gh-'));
6084
+ try {
6085
+ // Shallow clone — fast, minimal disk
6086
+ await execAsync('git', ['clone', '--depth', '1', '--single-branch', cloneUrl, join(tempDir, repo)], { timeout: 120000 });
6087
+ const repoDir = join(tempDir, repo);
6088
+ // Run full HMA scan + NanoMind (same pipeline as `secure` and `checkNpmPackage`)
6089
+ const scanner = new index_1.HardeningScanner();
6090
+ const result = await scanner.scan({ targetDir: repoDir, autoFix: false });
6091
+ // Run NanoMind semantic analysis and re-filter
6092
+ try {
6093
+ const { orchestrateNanoMind } = await Promise.resolve().then(() => __importStar(require('./nanomind-core/orchestrate.js')));
6094
+ const nmResult = await orchestrateNanoMind(repoDir, result.findings, { silent: true });
6095
+ const refiltered = await scanner.reapplyIgnoreFilters(nmResult.mergedFindings, repoDir);
6096
+ const projectType = result.projectType || 'library';
6097
+ result.findings = refiltered.filter((f) => !f.passed && f.file && scanner.findingAppliesTo(f, projectType));
6098
+ result.score = scanner.calculateScore(result.findings.filter((f) => !f.passed && !f.fixed)).score;
6099
+ }
6100
+ catch {
6101
+ // NanoMind unavailable — use base scan results
6102
+ }
6103
+ const failed = result.findings.filter(f => !f.passed);
6104
+ const critical = failed.filter(f => f.severity === 'critical');
6105
+ const high = failed.filter(f => f.severity === 'high');
6106
+ const medium = failed.filter(f => f.severity === 'medium');
6107
+ const low = failed.filter(f => f.severity === 'low');
6108
+ if (options.json) {
6109
+ writeJsonStdout({
6110
+ name: displayName,
6111
+ type: 'github-repo',
6112
+ source: 'local-scan',
6113
+ projectType: result.projectType,
6114
+ score: result.score,
6115
+ maxScore: result.maxScore,
6116
+ findings: result.findings,
6117
+ });
6118
+ return;
6119
+ }
6120
+ // Display results
6121
+ const scoreRatio = result.score / result.maxScore;
6122
+ const scoreColor = scoreRatio >= 0.7 ? colors.green : scoreRatio >= 0.4 ? colors.yellow : colors.red;
6123
+ console.log(`\n ${displayName} ${colors.dim}(GitHub)${RESET()}`);
6124
+ console.log(` Type: ${result.projectType}`);
6125
+ console.log(` Score: ${scoreColor}${result.score}/${result.maxScore}${RESET()}`);
6126
+ console.log(` Findings: ${critical.length} critical, ${high.length} high, ${medium.length} medium, ${low.length} low`);
6127
+ if (failed.length > 0) {
6128
+ console.log();
6129
+ const limit = options.verbose ? failed.length : 15;
6130
+ for (const f of failed.slice(0, limit)) {
6131
+ const sev = SEVERITY_DISPLAY[f.severity];
6132
+ const attackClass = f.attackClass ? ` (${f.attackClass})` : '';
6133
+ console.log(` ${sev.color()}${sev.symbol}${RESET()} ${f.name}: ${f.message}${colors.dim}${attackClass}${RESET()}`);
6134
+ }
6135
+ if (failed.length > limit) {
6136
+ console.log(`\n ... and ${failed.length - limit} more (use --verbose to see all)`);
6137
+ }
6138
+ }
6139
+ else {
6140
+ console.log(`\n ${colors.green}No security issues found.${RESET()}`);
6141
+ }
6142
+ // Step 3: Community contribution
6143
+ if (process.stdin.isTTY && !globalCiMode) {
6144
+ const scanCount = incrementScanCounter();
6145
+ if (scanCount >= 3 && !hasContributeChoice()) {
6146
+ console.log();
6147
+ console.log(` ${colors.dim}Your scans help other developers make safer choices.`);
6148
+ console.log(` Sharing adds anonymized results to the OpenA2A trust registry`);
6149
+ console.log(` so others can check packages before installing.${RESET()}`);
6150
+ const readline = await Promise.resolve().then(() => __importStar(require('node:readline')));
6151
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
6152
+ const answer = await new Promise(resolve => {
6153
+ rl.question(`\n Share scans with the community? [Y/n] `, resolve);
6154
+ });
6155
+ rl.close();
6156
+ const wantsToShare = answer.trim().toLowerCase() !== 'n';
6157
+ saveContributeChoice(wantsToShare);
6158
+ if (wantsToShare) {
6159
+ const ok = await publishToRegistry(displayName, result);
6160
+ if (ok) {
6161
+ console.error(` ${colors.green}Shared. Future scans will auto-share.${RESET()}`);
6162
+ }
6163
+ else {
6164
+ queuePendingScan(displayName, result);
6165
+ }
6166
+ }
6167
+ }
6168
+ else if (isContributeEnabled()) {
6169
+ flushPendingScans();
6170
+ const ok = await publishToRegistry(displayName, result);
6171
+ if (!ok)
6172
+ queuePendingScan(displayName, result);
6173
+ }
6174
+ }
6175
+ console.log(`\n Full project scan: ${CLI_PREFIX} secure <dir>`);
6176
+ console.log();
6177
+ if (critical.length > 0 || high.length > 0)
6178
+ process.exit(1);
6179
+ }
6180
+ catch (err) {
6181
+ const message = err instanceof Error ? err.message : String(err);
6182
+ if (message.includes('128') || message.includes('not found') || message.includes('Repository not found')) {
6183
+ console.error(`Error: Repository "${displayName}" not found on GitHub.`);
6184
+ console.error(`\nVerify the URL: https://github.com/${displayName}`);
6185
+ }
6186
+ else if (message.includes('timeout') || message.includes('Timeout')) {
6187
+ console.error(`Error: Cloning "${displayName}" timed out (120s). The repo may be too large.`);
6188
+ console.error(`\nTry cloning manually and scanning the local path:`);
6189
+ console.error(` git clone --depth 1 ${cloneUrl}`);
6190
+ console.error(` ${CLI_PREFIX} check ./${repo}/`);
6191
+ }
6192
+ else {
6193
+ console.error(`Error: ${message}`);
6194
+ }
6195
+ process.exit(1);
6196
+ }
6197
+ finally {
6198
+ await rm(tempDir, { recursive: true, force: true });
6199
+ }
6200
+ }
5949
6201
  /**
5950
6202
  * Download an npm package, run full HMA secure scan, display results, clean up.
5951
6203
  * Checks the registry first; only downloads if data is missing or stale.
@@ -6087,6 +6339,20 @@ async function checkNpmPackage(name, options) {
6087
6339
  // Clean npm error messages
6088
6340
  if (message.includes('404') || message.includes('Not Found')) {
6089
6341
  console.error(`Error: Package "${name}" not found on npm.`);
6342
+ // Suggest similar packages via npm registry search
6343
+ try {
6344
+ const suggestions = await suggestSimilarPackages(name);
6345
+ if (suggestions.length > 0) {
6346
+ console.error(`\nDid you mean?`);
6347
+ for (const s of suggestions) {
6348
+ console.error(` ${s}`);
6349
+ }
6350
+ console.error();
6351
+ }
6352
+ }
6353
+ catch {
6354
+ // Search failed — just show the original error
6355
+ }
6090
6356
  }
6091
6357
  else {
6092
6358
  console.error(`Error: ${message}`);