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/.integrity-manifest.json +1 -1
- package/dist/cli.js +270 -4
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
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
|
|
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}`);
|