Skip to content

Commit 080e592

Browse files
committed
v1.3.0 slopsquatting feature
1 parent d9ab6d4 commit 080e592

File tree

6 files changed

+729
-18
lines changed

6 files changed

+729
-18
lines changed

README.md

+90-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ VibeSafe helps developers quickly check their projects for common security issue
1212
- 🔐 **Secret Scanning**
1313
Flags AWS keys, JWTs, SSH keys, high-entropy strings, and secrets in `.env` files.
1414

15+
- 🛡️ **Secure Package Installation** (`vibesafe install`)
16+
Helps prevent slopsquatting & typosquatting by checking package trustworthiness before installing.
17+
1518
- 📦 **Dependency Vulnerability Detection**
1619
Checks `package.json` dependencies against the [OSV.dev](https://osv.dev) vulnerability database. *(Direct deps only for now — lockfile support coming soon).*
1720

@@ -110,6 +113,92 @@ vibesafe scan -r ai-report.md
110113
vibesafe scan --high-only
111114
```
112115

116+
## 🛡️ Secure Package Installation: `vibesafe install`
117+
118+
VibeSafe now includes a command to help you install npm packages more safely, protecting against typosquatting and other suspicious packages.
119+
120+
**Basic Usage:**
121+
122+
Use `vibesafe install` (or its alias `vibesafe i`) just like you would use `npm install`:
123+
124+
```bash
125+
vibesafe install <package-name>
126+
# Example
127+
vibesafe install express
128+
```
129+
130+
**How it Works:**
131+
132+
Before installing, `vibesafe install` performs several heuristic checks on the package(s) you want to install:
133+
134+
* **Package Age:** Flags very new packages (e.g., published within the last 30 days).
135+
* **Download Volume:** Flags packages with very low download counts.
136+
* **README Presence:** Checks for a missing or placeholder README file.
137+
* **License:** Verifies if a license is specified.
138+
* **Repository/Homepage:** Checks for a linked code repository or homepage.
139+
140+
**User Confirmation:**
141+
142+
If any of these checks raise a warning, VibeSafe will list the concerns and ask for your confirmation before proceeding with the installation:
143+
144+
```shell
145+
$ vibesafe install some-new-package
146+
[vibesafe] Processing package "some-new-package" (1 of 1)...
147+
[vibesafe] Fetching metadata for "some-new-package"...
148+
[vibesafe] Successfully fetched metadata for "some-new-package".
149+
Created: <date>
150+
[vibesafe] ⚠ Found 2 heuristic warning(s) for "some-new-package":
151+
- Package "some-new-package" was published recently... (Severity: Medium)
152+
Details: Published 0 days ago (threshold: 30 days)
153+
- Package "some-new-package" has a placeholder README... (Severity: Low)
154+
Details: ...
155+
Are you sure you want to install "some-new-package"? [y/N]
156+
```
157+
158+
* Enter `y` or `yes` to proceed despite warnings.
159+
* Enter `n` or press Enter to abort the installation.
160+
161+
**Automatic Yes (for CI/Scripts):**
162+
163+
Use the `--yes` flag to automatically accept warnings and proceed with installation. This is useful in non-interactive environments.
164+
165+
```bash
166+
vibesafe install <package-name> --yes
167+
```
168+
If `--yes` is not used in a non-interactive environment (e.g., a script without a TTY), VibeSafe will abort installation if any warnings are found.
169+
170+
**Installing Multiple Packages:**
171+
172+
You can specify multiple packages to install in one command. VibeSafe will process them sequentially:
173+
174+
```bash
175+
vibesafe install packageA packageB packageC
176+
```
177+
If an issue is found with one package and you choose to abort, subsequent packages in the list will not be processed.
178+
179+
**Passing Flags to npm (e.g., `--save-dev`):**
180+
181+
If you need to pass additional arguments directly to the `npm install` command (like `--save-dev`, `--legacy-peer-deps`, etc.), use the `--` separator after your package names and before the npm flags:
182+
183+
```bash
184+
vibesafe install <package-name> -- --save-dev
185+
vibesafe install packageA packageB -- --save-dev --legacy-peer-deps
186+
```
187+
188+
### Future Enhancements for `vibesafe install` (TODO)
189+
190+
We plan to enhance `vibesafe install` with more advanced security features:
191+
192+
* **Typosquatting & Name Similarity Detection:**
193+
* Detect package names that are very similar to popular packages (e.g., using Levenshtein distance).
194+
* Suggest correct package names if a typo is suspected (e.g., "Did you mean `express`?").
195+
* **Malicious Package Database Check:**
196+
* Integrate with services like OSV.dev to check if a package version is known to be malicious.
197+
* **Installation Script Warnings:**
198+
* Inspect package manifests for `preinstall`/`postinstall` scripts and warn the user.
199+
* **Configurable Rules:**
200+
* Allow users to customize thresholds for warnings (e.g., package age, download counts) via a configuration file.
201+
113202
## 🛑📁 Ignoring Files (.vibesafeignore)
114203

115204
Create a `.vibesafeignore` file in the root of the directory being scanned. Add file paths or glob patterns (one per line) to exclude them from the scan. The syntax is the same as `.gitignore`.
@@ -148,6 +237,6 @@ For questions or commercial partnership inquiries, contact **vibesafepackage@gma
148237
## 📛 Trademark Notice
149238

150239
**VibeSafe™** is a trademark of Secret Society LLC.
151-
Use of the name VibeSafe for derivative tools, competing products, or commercial services is **not permitted without prior written consent.**
240+
Use of the name "VibeSafe" for derivative tools, competing products, or commercial services is **not permitted without prior written consent.**
152241

153242
You are free to fork or build upon this code under the [MIT License](./LICENSE), but please use a different name and branding for public or commercial distributions.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "vibesafe",
3-
"version": "1.2.0",
3+
"version": "1.3.0",
44
"description": "A CLI tool to scan your codebase for security vibes.",
55
"main": "dist/index.js",
66
"bin": {

src/index.ts

+188
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ import { scanForLoggingIssues, LoggingFinding } from './scanners/logging';
1919
import { scanForHttpClientIssues, HttpClientFinding } from './scanners/httpClient';
2020
import { detectTechnologies, DetectedTechnologies } from './frameworkDetection';
2121

22+
// --- VibeSafe Installer Imports ---
23+
import { fetchPackageMetadata, fetchPackageDownloads } from './installer/npmRegistryClient';
24+
import { checkPackageAge, HeuristicWarning, checkDownloadVolume, checkReadmePresence, checkLicensePresence, checkRepositoryPresence } from './installer/heuristicChecks';
25+
import readline from 'readline'; // Added for user input
26+
import { spawn } from 'child_process'; // Added for spawning npm
27+
// We will add more imports from './installer/*' here as we build out features
28+
2229
// Define a combined finding type if needed later
2330

2431
// Helper for coloring severities
@@ -577,6 +584,187 @@ program.command('scan')
577584

578585
});
579586

587+
program.command('install')
588+
.alias('i')
589+
.description('Install a package safely after security checks.')
590+
.argument('<package>', 'Package to install (e.g., express, [email protected])')
591+
.argument('[npmArgs...]', 'Additional arguments to pass to npm (e.g., --save-dev, --legacy-peer-deps)')
592+
.option('--yes', 'Automatically answer yes to prompts and run non-interactively')
593+
.action(async (packageNameArg: string, additionalArgs: string[], options: { yes?: boolean }) => {
594+
595+
const packagesToProcess: string[] = [packageNameArg];
596+
const npmPassThroughFlags: string[] = [];
597+
598+
for (const arg of additionalArgs) {
599+
if (arg.startsWith('-')) {
600+
npmPassThroughFlags.push(arg);
601+
} else {
602+
packagesToProcess.push(arg);
603+
}
604+
}
605+
606+
let overallExitCode = 0;
607+
let stopAllProcessing = false;
608+
609+
for (let i = 0; i < packagesToProcess.length; i++) {
610+
const currentPkgName = packagesToProcess[i];
611+
console.log(chalk.magenta(`\n[vibesafe] Processing package "${chalk.cyan(currentPkgName)}" (${i + 1} of ${packagesToProcess.length})...`));
612+
if (npmPassThroughFlags.length > 0) {
613+
console.log(chalk.dim(` with npm flags: ${npmPassThroughFlags.join(' ')}`));
614+
}
615+
616+
// Flags to manage flow for the current package
617+
let proceedWithInstallation = false;
618+
let installationAbortedManually = false;
619+
let errorOccurredDuringChecks = false;
620+
621+
try {
622+
// Reset for each package, but if it becomes true, we stop all.
623+
// This logic is largely moved from the original single-package handler
624+
625+
console.log(`[vibesafe] Fetching metadata for \"${chalk.cyan(currentPkgName)}\"...`);
626+
const metadata = await fetchPackageMetadata(currentPkgName);
627+
console.log(chalk.green(`[vibesafe] Successfully fetched metadata for \"${chalk.cyan(currentPkgName)}\".`));
628+
629+
if (metadata.time?.created) {
630+
console.log(` Created: ${new Date(metadata.time.created).toLocaleDateString()}`);
631+
}
632+
633+
// --- Perform Heuristic Checks ---
634+
const warnings: HeuristicWarning[] = [];
635+
636+
const ageWarning = checkPackageAge(metadata);
637+
if (ageWarning) warnings.push(ageWarning);
638+
639+
const downloadsData = await fetchPackageDownloads(currentPkgName);
640+
if (downloadsData.error) {
641+
console.warn(chalk.yellow(`[WARN] Could not fetch download stats for \"${chalk.cyan(currentPkgName)}\": ${downloadsData.error}`));
642+
} else {
643+
console.log(` Downloads (last month): ${downloadsData.downloads !== undefined ? downloadsData.downloads.toLocaleString() : 'N/A'}`);
644+
const downloadWarning = checkDownloadVolume(currentPkgName, downloadsData);
645+
if (downloadWarning) warnings.push(downloadWarning);
646+
}
647+
648+
const readmeWarning = checkReadmePresence(metadata);
649+
if (readmeWarning) warnings.push(readmeWarning);
650+
651+
const licenseWarning = checkLicensePresence(metadata);
652+
if (licenseWarning) warnings.push(licenseWarning);
653+
654+
const repoWarning = checkRepositoryPresence(metadata);
655+
if (repoWarning) warnings.push(repoWarning);
656+
657+
// --- Process Aggregated Warnings ---
658+
if (warnings.length === 0) {
659+
console.log(chalk.green(`[vibesafe] ✔ No heuristic warnings found for \"${chalk.cyan(currentPkgName)}\". Proceeding to installation.`));
660+
proceedWithInstallation = true;
661+
} else {
662+
console.log(chalk.yellow(`[vibesafe] ⚠ Found ${warnings.length} heuristic warning(s) for \"${chalk.cyan(currentPkgName)}\":`));
663+
warnings.forEach(w => {
664+
console.warn(chalk.yellow(` - ${w.message} (Severity: ${w.severity})`));
665+
if (w.details) {
666+
let detailsString = typeof w.details === 'string' ? w.details : JSON.stringify(w.details);
667+
if (w.type === 'PackageAge' && typeof w.details === 'object' && w.details !== null && 'ageInDays' in w.details && 'thresholdDays' in w.details) {
668+
detailsString = `Published ${Math.floor(w.details.ageInDays)} days ago (threshold: ${w.details.thresholdDays} days)`;
669+
}
670+
console.warn(chalk.yellow(` Details: ${detailsString}`));
671+
}
672+
});
673+
674+
if (options.yes) {
675+
console.log(chalk.yellow('[vibesafe] --yes flag detected. Proceeding with installation despite warnings.'));
676+
proceedWithInstallation = true;
677+
} else if (!process.stdin.isTTY) {
678+
console.log(chalk.red('[vibesafe] Non-interactive input detected. Aborting installation due to warnings.'));
679+
console.log(chalk.red('[vibesafe] Use the --yes flag to force installation in non-interactive mode if necessary.'));
680+
overallExitCode = 1;
681+
stopAllProcessing = true; // Stop processing further packages
682+
} else {
683+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
684+
const answer = await new Promise<string>(resolve =>
685+
rl.question(chalk.blueBright(`Are you sure you want to install \"${chalk.cyan(currentPkgName)}\"? [y/N] `), resolve)
686+
);
687+
rl.close();
688+
if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
689+
console.log(chalk.green(`[vibesafe] User approved installation for \"${chalk.cyan(currentPkgName)}\".`));
690+
proceedWithInstallation = true;
691+
} else {
692+
console.log(chalk.red(`[vibesafe] User aborted installation for \"${chalk.cyan(currentPkgName)}\".`));
693+
installationAbortedManually = true;
694+
overallExitCode = 1;
695+
stopAllProcessing = true; // Stop processing further packages
696+
}
697+
}
698+
}
699+
} catch (error: any) {
700+
console.error(chalk.red(`[vibesafe] Error during security checks for \"${chalk.cyan(currentPkgName)}\": ${error.message}`));
701+
errorOccurredDuringChecks = true;
702+
overallExitCode = 1;
703+
stopAllProcessing = true; // Stop processing further packages
704+
}
705+
706+
if (stopAllProcessing) {
707+
console.log(chalk.red(`[vibesafe] Aborting further package processing due to previous error or user cancellation.`));
708+
break; // Exit the loop over packages
709+
}
710+
711+
if (proceedWithInstallation && !installationAbortedManually && !errorOccurredDuringChecks) {
712+
console.log(chalk.blue(`[vibesafe] Invoking npm install for \"${chalk.cyan(currentPkgName)}\"` +
713+
`${npmPassThroughFlags.length > 0 ? ` with flags: ${chalk.dim(npmPassThroughFlags.join(' '))}` : chalk.dim(' (no additional flags)')}...`));
714+
715+
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
716+
const installProcess = spawn(npmCommand, ['install', currentPkgName, ...npmPassThroughFlags], { stdio: 'inherit' });
717+
718+
const npmPromise = new Promise<void>((resolve, reject) => {
719+
installProcess.on('error', (err) => {
720+
console.error(chalk.red(`[vibesafe] Failed to start npm process for \"${chalk.cyan(currentPkgName)}\": ${err.message}`));
721+
overallExitCode = 1;
722+
stopAllProcessing = true;
723+
reject(err);
724+
});
725+
726+
installProcess.on('close', (code) => {
727+
if (code === 0) {
728+
console.log(chalk.green(`[vibesafe] Successfully installed \"${chalk.cyan(currentPkgName)}\".`));
729+
resolve();
730+
} else {
731+
console.error(chalk.red(`[vibesafe] npm install for \"${chalk.cyan(currentPkgName)}\" failed with exit code ${code}.`));
732+
overallExitCode = 1;
733+
stopAllProcessing = true;
734+
reject(new Error(`npm install failed with code ${code}`));
735+
}
736+
});
737+
});
738+
739+
try {
740+
await npmPromise;
741+
} catch (npmError) {
742+
// Error already logged, overallExitCode and stopAllProcessing are set.
743+
// Just need to ensure we break the loop if not already handled by stopAllProcessing check.
744+
if (stopAllProcessing) break;
745+
}
746+
} else if (installationAbortedManually || errorOccurredDuringChecks) {
747+
// Message already logged, overallExitCode and stopAllProcessing are set.
748+
if (stopAllProcessing) break;
749+
}
750+
// If !proceedWithInstallation due to non-interactive mode with warnings (and no --yes), already handled.
751+
752+
if (stopAllProcessing && i < packagesToProcess.length -1) { // if we stopped and there were more packages
753+
console.log(chalk.yellow(`[vibesafe] Remaining packages (${packagesToProcess.length - 1 - i}) were not processed.`));
754+
break;
755+
}
756+
} // end for loop over packages
757+
758+
if (overallExitCode !== 0) {
759+
process.exitCode = overallExitCode;
760+
console.log(chalk.redBright(`[vibesafe] Finished 'install' command with errors or cancellations.`));
761+
} else if (!stopAllProcessing) {
762+
console.log(chalk.greenBright(`[vibesafe] Successfully processed all requested packages.`));
763+
}
764+
// No explicit process.exit(0) needed, as it's the default if process.exitCode is not set to non-zero.
765+
766+
});
767+
580768
// Helper for sorting console output - Add Info level
581769
function severityToSortOrder(severity: FindingSeverity | SecretFinding['severity'] | UploadFinding['severity'] | LoggingFinding['severity'] | EndpointFinding['severity'] | RateLimitFinding['severity'] | HttpClientFinding['severity']): number {
582770
switch (severity) {

0 commit comments

Comments
 (0)