ghostimport 0.1.1 → 0.1.3
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/README.md +32 -0
- package/dist/cli.js +117 -91
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -40,6 +40,8 @@ The result: your build fails, or your CI breaks at 2am, or — in the worst case
|
|
|
40
40
|
|
|
41
41
|
## Install
|
|
42
42
|
|
|
43
|
+
Requires Node.js 22 or later.
|
|
44
|
+
|
|
43
45
|
```bash
|
|
44
46
|
# Run once (no install needed)
|
|
45
47
|
npx ghostimport
|
|
@@ -71,12 +73,27 @@ ghostimport --json
|
|
|
71
73
|
# Skip "missing from package.json" warnings
|
|
72
74
|
ghostimport --no-undeclared
|
|
73
75
|
|
|
76
|
+
# Watch mode (re-scans on file changes)
|
|
77
|
+
ghostimport --watch
|
|
78
|
+
|
|
74
79
|
# Help
|
|
75
80
|
ghostimport --help
|
|
76
81
|
```
|
|
77
82
|
|
|
78
83
|
### Add to CI (GitHub Actions)
|
|
79
84
|
|
|
85
|
+
Use the built-in action for the simplest setup:
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
- name: Check for hallucinated packages
|
|
89
|
+
uses: FGuerreir0/ghostimport@main
|
|
90
|
+
with:
|
|
91
|
+
path: '.'
|
|
92
|
+
scary: 'false'
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Or run directly:
|
|
96
|
+
|
|
80
97
|
```yaml
|
|
81
98
|
- name: Check for hallucinated packages
|
|
82
99
|
run: npx ghostimport --quiet
|
|
@@ -84,6 +101,18 @@ ghostimport --help
|
|
|
84
101
|
|
|
85
102
|
This step will fail (exit code 1) if any hallucinated packages are found.
|
|
86
103
|
|
|
104
|
+
### Pre-commit
|
|
105
|
+
|
|
106
|
+
Add to `.pre-commit-config.yaml`:
|
|
107
|
+
|
|
108
|
+
```yaml
|
|
109
|
+
repos:
|
|
110
|
+
- repo: https://github.com/FGuerreir0/ghostimport
|
|
111
|
+
rev: main
|
|
112
|
+
hooks:
|
|
113
|
+
- id: ghostimport
|
|
114
|
+
```
|
|
115
|
+
|
|
87
116
|
---
|
|
88
117
|
|
|
89
118
|
## Programmatic API
|
|
@@ -163,6 +192,9 @@ interface ScanOptions {
|
|
|
163
192
|
**Automatically ignores:**
|
|
164
193
|
- Node.js built-ins (`fs`, `path`, `crypto`, `node:*`, ...)
|
|
165
194
|
- Relative imports (`./`, `../`)
|
|
195
|
+
- Path aliases (`@/`, `~/`, `$lib/`, and tsconfig `paths`)
|
|
196
|
+
- URL/protocol imports (`https:`, `data:`, `bun:`, ...)
|
|
197
|
+
- Virtual modules (`virtual:`, Vite/Rollup internals)
|
|
166
198
|
- `node_modules/`, `dist/`, `.git/`, `build/`
|
|
167
199
|
|
|
168
200
|
**Supported file types:** `.js` `.jsx` `.ts` `.tsx` `.mjs` `.cjs`
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import path5 from "path";
|
|
5
|
-
import { readFileSync } from "fs";
|
|
5
|
+
import { readFileSync, watch as fsWatch } from "fs";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
|
|
8
8
|
// src/scan.ts
|
|
@@ -268,7 +268,7 @@ import path3 from "path";
|
|
|
268
268
|
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".next", "coverage", ".cache"]);
|
|
269
269
|
var CODE_EXTS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
|
|
270
270
|
function walkFiles(dir) {
|
|
271
|
-
const
|
|
271
|
+
const results = [];
|
|
272
272
|
function walk(current) {
|
|
273
273
|
let entries;
|
|
274
274
|
try {
|
|
@@ -280,12 +280,12 @@ function walkFiles(dir) {
|
|
|
280
280
|
if (entry.isDirectory()) {
|
|
281
281
|
if (!SKIP_DIRS.has(entry.name)) walk(path3.join(current, entry.name));
|
|
282
282
|
} else if (entry.isFile()) {
|
|
283
|
-
if (CODE_EXTS.has(path3.extname(entry.name)))
|
|
283
|
+
if (CODE_EXTS.has(path3.extname(entry.name))) results.push(path3.join(current, entry.name));
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
286
|
}
|
|
287
287
|
walk(dir);
|
|
288
|
-
return
|
|
288
|
+
return results;
|
|
289
289
|
}
|
|
290
290
|
function readPackageJsonDeps(dir) {
|
|
291
291
|
const pkgPath = path3.join(dir, "package.json");
|
|
@@ -395,7 +395,7 @@ async function scan(targetDir2, { onProgress, useCache = true, scary = false, co
|
|
|
395
395
|
const allPkgs = [...importMap.keys()].filter(
|
|
396
396
|
(pkg) => !matchesIgnore(pkg, conf.ignore) && !workspacePkgs.has(pkg) && !tsconfigAliases.has(pkg)
|
|
397
397
|
);
|
|
398
|
-
const
|
|
398
|
+
const results = {
|
|
399
399
|
scanned: files.length,
|
|
400
400
|
packages: allPkgs.length,
|
|
401
401
|
hallucinated: [],
|
|
@@ -425,24 +425,24 @@ async function scan(targetDir2, { onProgress, useCache = true, scary = false, co
|
|
|
425
425
|
}
|
|
426
426
|
onProgress?.({ pkg, exists, error, total: allPkgs.length, done: i + j + 1 });
|
|
427
427
|
if (exists === false) {
|
|
428
|
-
|
|
428
|
+
results.hallucinated.push({ pkg, files: matchedFiles });
|
|
429
429
|
} else if (exists === null && error) {
|
|
430
|
-
|
|
430
|
+
results.errors.push({ pkg, error, files: matchedFiles });
|
|
431
431
|
} else if (exists === true && !declaredDeps.has(pkg)) {
|
|
432
|
-
|
|
432
|
+
results.notInPackageJson.push({ pkg, files: matchedFiles });
|
|
433
433
|
}
|
|
434
434
|
}
|
|
435
435
|
}
|
|
436
436
|
if (useCache) saveCache(cache);
|
|
437
|
-
|
|
437
|
+
results.cacheHits = cacheHits;
|
|
438
438
|
if (scary) {
|
|
439
|
-
for (const { pkg, files: matchedFiles } of
|
|
440
|
-
|
|
439
|
+
for (const { pkg, files: matchedFiles } of results.hallucinated) {
|
|
440
|
+
results.scary.push({ pkg, files: matchedFiles, type: "available" });
|
|
441
441
|
}
|
|
442
|
-
for (const { pkg, files: matchedFiles } of
|
|
442
|
+
for (const { pkg, files: matchedFiles } of results.notInPackageJson) {
|
|
443
443
|
const info = await checkScary(pkg);
|
|
444
444
|
if (info.exists === true && info.risk !== "low") {
|
|
445
|
-
|
|
445
|
+
results.scary.push({
|
|
446
446
|
pkg,
|
|
447
447
|
files: matchedFiles,
|
|
448
448
|
type: "suspicious",
|
|
@@ -456,7 +456,7 @@ async function scan(targetDir2, { onProgress, useCache = true, scary = false, co
|
|
|
456
456
|
}
|
|
457
457
|
}
|
|
458
458
|
}
|
|
459
|
-
return
|
|
459
|
+
return results;
|
|
460
460
|
}
|
|
461
461
|
|
|
462
462
|
// src/cli.ts
|
|
@@ -479,6 +479,7 @@ var targetDir = path5.resolve(args.find((a) => !a.startsWith("--") && !a.startsW
|
|
|
479
479
|
var flags = {
|
|
480
480
|
quiet: args.includes("--quiet") || args.includes("-q"),
|
|
481
481
|
json: args.includes("--json"),
|
|
482
|
+
watch: args.includes("--watch") || args.includes("-w"),
|
|
482
483
|
noUndeclared: args.includes("--no-undeclared"),
|
|
483
484
|
noCache: args.includes("--no-cache"),
|
|
484
485
|
scary: args.includes("--scary"),
|
|
@@ -499,6 +500,7 @@ ${c.bold("Usage:")}
|
|
|
499
500
|
${c.bold("Options:")}
|
|
500
501
|
--quiet, -q Only show problems (no progress)
|
|
501
502
|
--json Output results as JSON
|
|
503
|
+
--watch, -w Watch for file changes and re-scan
|
|
502
504
|
--no-undeclared Skip "imported but not in package.json" warnings
|
|
503
505
|
--scary Check for supply chain attack risk (squatting)
|
|
504
506
|
--no-cache Skip the local registry cache
|
|
@@ -522,97 +524,121 @@ ${c.bold("Examples:")}
|
|
|
522
524
|
process.exit(0);
|
|
523
525
|
}
|
|
524
526
|
var config = loadConfig(targetDir);
|
|
525
|
-
|
|
526
|
-
|
|
527
|
+
async function runScan() {
|
|
528
|
+
if (!flags.quiet && !flags.json) {
|
|
529
|
+
console.log(`
|
|
527
530
|
${c.bold("ghostimport")} ${c.gray(`v${version}`)}`);
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
}
|
|
532
|
-
var lastProgress = "";
|
|
533
|
-
var results = await scan(targetDir, {
|
|
534
|
-
useCache: !flags.noCache,
|
|
535
|
-
scary: flags.scary,
|
|
536
|
-
config,
|
|
537
|
-
onProgress: flags.json || flags.quiet ? void 0 : ({ pkg, done, total }) => {
|
|
538
|
-
const pct = Math.round(done / total * 100);
|
|
539
|
-
const bar = "\u2588".repeat(Math.floor(pct / 5)) + "\u2591".repeat(20 - Math.floor(pct / 5));
|
|
540
|
-
const line = ` ${c.gray(bar)} ${pct}% ${c.dim(pkg.slice(0, 30))}`;
|
|
541
|
-
process.stdout.write("\r" + line + " ".repeat(Math.max(0, lastProgress.length - line.length)));
|
|
542
|
-
lastProgress = line;
|
|
531
|
+
console.log(c.gray(`Scanning ${targetDir}`));
|
|
532
|
+
if (flags.scary) console.log(c.magenta(" \u26A0 Scary mode: checking supply chain risk"));
|
|
533
|
+
console.log();
|
|
543
534
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
console.log(c.bold(c.red(` \u2717 ${results.hallucinated.length} hallucinated package${results.hallucinated.length > 1 ? "s" : ""} (do not exist on npm):
|
|
560
|
-
`)));
|
|
561
|
-
for (const { pkg, files } of results.hallucinated) {
|
|
562
|
-
console.log(` ${c.red("\u25CF")} ${c.bold(pkg)}`);
|
|
563
|
-
for (const f of files.slice(0, 3)) console.log(` ${c.gray("\u21B3")} ${c.dim(f)}`);
|
|
564
|
-
if (files.length > 3) console.log(` ${c.gray(`\u21B3 ...and ${files.length - 3} more files`)}`);
|
|
535
|
+
let lastProgress = "";
|
|
536
|
+
const results = await scan(targetDir, {
|
|
537
|
+
useCache: !flags.noCache,
|
|
538
|
+
scary: flags.scary,
|
|
539
|
+
config,
|
|
540
|
+
onProgress: flags.json || flags.quiet ? void 0 : ({ pkg, done, total }) => {
|
|
541
|
+
const pct = Math.round(done / total * 100);
|
|
542
|
+
const bar = "\u2588".repeat(Math.floor(pct / 5)) + "\u2591".repeat(20 - Math.floor(pct / 5));
|
|
543
|
+
const line = ` ${c.gray(bar)} ${pct}% ${c.dim(pkg.slice(0, 30))}`;
|
|
544
|
+
process.stdout.write("\r" + line + " ".repeat(Math.max(0, lastProgress.length - line.length)));
|
|
545
|
+
lastProgress = line;
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
if (!flags.json && !flags.quiet && lastProgress) {
|
|
549
|
+
process.stdout.write("\r" + " ".repeat(lastProgress.length + 5) + "\r");
|
|
565
550
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
551
|
+
if (flags.json) {
|
|
552
|
+
console.log(JSON.stringify(results, null, 2));
|
|
553
|
+
return results.hallucinated.length;
|
|
554
|
+
}
|
|
555
|
+
const totalIssues = results.hallucinated.length + (flags.noUndeclared ? 0 : results.notInPackageJson.length);
|
|
556
|
+
console.log(
|
|
557
|
+
` ${c.gray("Scanned")} ${c.cyan(results.scanned + " files")} \xB7 ${c.cyan(results.packages + " unique packages")} checked` + (results.cacheHits > 0 ? ` ${c.gray(`(${results.cacheHits} cached)`)}` : "") + "\n"
|
|
558
|
+
);
|
|
559
|
+
if (results.hallucinated.length === 0) {
|
|
560
|
+
console.log(c.green(" \u2713 No hallucinated packages found"));
|
|
561
|
+
} else {
|
|
562
|
+
console.log(c.bold(c.red(` \u2717 ${results.hallucinated.length} hallucinated package${results.hallucinated.length > 1 ? "s" : ""} (do not exist on npm):
|
|
573
563
|
`)));
|
|
574
|
-
for (const { pkg } of
|
|
575
|
-
console.log(` ${c.
|
|
576
|
-
console.log(` ${c.
|
|
577
|
-
console.log(` ${c.
|
|
564
|
+
for (const { pkg, files } of results.hallucinated) {
|
|
565
|
+
console.log(` ${c.red("\u25CF")} ${c.bold(pkg)}`);
|
|
566
|
+
for (const f of files.slice(0, 3)) console.log(` ${c.gray("\u21B3")} ${c.dim(f)}`);
|
|
567
|
+
if (files.length > 3) console.log(` ${c.gray(`\u21B3 ...and ${files.length - 3} more files`)}`);
|
|
578
568
|
}
|
|
579
569
|
}
|
|
580
|
-
if (
|
|
570
|
+
if (flags.scary && results.scary.length > 0) {
|
|
581
571
|
console.log();
|
|
582
|
-
|
|
572
|
+
const available = results.scary.filter((s) => s.type === "available");
|
|
573
|
+
const suspicious = results.scary.filter((s) => s.type === "suspicious");
|
|
574
|
+
if (available.length > 0) {
|
|
575
|
+
console.log(c.bold(c.magenta(` \u{1F480} ${available.length} package name${available.length > 1 ? "s" : ""} available for malicious registration:
|
|
576
|
+
`)));
|
|
577
|
+
for (const { pkg } of available) {
|
|
578
|
+
console.log(` ${c.magenta("\u25CF")} ${c.bold(pkg)}`);
|
|
579
|
+
console.log(` ${c.red("\u21B3 Anyone can register this name with a malicious postinstall script")}`);
|
|
580
|
+
console.log(` ${c.red("\u21B3 If installed, it could exfiltrate .env, tokens, SSH keys")}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (suspicious.length > 0) {
|
|
584
|
+
console.log();
|
|
585
|
+
console.log(c.bold(c.magenta(` \u{1F575}\uFE0F ${suspicious.length} suspicious package${suspicious.length > 1 ? "s" : ""} (potential squats):
|
|
583
586
|
`)));
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
587
|
+
for (const entry of suspicious) {
|
|
588
|
+
if (entry.type !== "suspicious") continue;
|
|
589
|
+
console.log(` ${c.magenta("\u25CF")} ${c.bold(entry.pkg)} ${c.gray(`(created ${entry.created}, ${entry.downloads ?? "?"} downloads/week)`)}`);
|
|
590
|
+
for (const flag of entry.flags) console.log(` ${c.yellow("\u21B3")} ${flag}`);
|
|
591
|
+
}
|
|
588
592
|
}
|
|
589
593
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
console.log(c.bold(c.yellow(` \u26A0 ${results.notInPackageJson.length} package${results.notInPackageJson.length > 1 ? "s" : ""} imported but missing from package.json:
|
|
594
|
+
if (!flags.noUndeclared && results.notInPackageJson.length > 0) {
|
|
595
|
+
console.log();
|
|
596
|
+
console.log(c.bold(c.yellow(` \u26A0 ${results.notInPackageJson.length} package${results.notInPackageJson.length > 1 ? "s" : ""} imported but missing from package.json:
|
|
594
597
|
`)));
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
+
for (const { pkg, files } of results.notInPackageJson.slice(0, 10)) {
|
|
599
|
+
console.log(` ${c.yellow("\u25CF")} ${c.bold(pkg)}`);
|
|
600
|
+
for (const f of files.slice(0, 2)) console.log(` ${c.gray("\u21B3")} ${c.dim(f)}`);
|
|
601
|
+
}
|
|
602
|
+
if (results.notInPackageJson.length > 10) {
|
|
603
|
+
console.log(` ${c.gray(` ...and ${results.notInPackageJson.length - 10} more`)}`);
|
|
604
|
+
}
|
|
598
605
|
}
|
|
599
|
-
if (results.
|
|
600
|
-
console.log(
|
|
606
|
+
if (results.errors.length > 0) {
|
|
607
|
+
console.log();
|
|
608
|
+
console.log(c.gray(` \u26A1 ${results.errors.length} package(s) could not be checked (network/timeout)`));
|
|
601
609
|
}
|
|
602
|
-
}
|
|
603
|
-
if (results.errors.length > 0) {
|
|
604
610
|
console.log();
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
} else if (flags.scary && results.scary.length > 0) {
|
|
611
|
-
const scaryCount = results.scary.filter((s) => s.type === "available").length;
|
|
612
|
-
console.log(c.red(c.bold(` Found ${totalIssues} issue${totalIssues > 1 ? "s" : ""} \xB7 ${scaryCount} supply chain risk${scaryCount > 1 ? "s" : ""}
|
|
611
|
+
if (totalIssues === 0 && results.scary.length === 0) {
|
|
612
|
+
console.log(c.green(c.bold(" All good! \u2713\n")));
|
|
613
|
+
} else if (flags.scary && results.scary.length > 0) {
|
|
614
|
+
const scaryCount = results.scary.filter((s) => s.type === "available").length;
|
|
615
|
+
console.log(c.red(c.bold(` Found ${totalIssues} issue${totalIssues > 1 ? "s" : ""} \xB7 ${scaryCount} supply chain risk${scaryCount > 1 ? "s" : ""}
|
|
613
616
|
`)));
|
|
614
|
-
} else {
|
|
615
|
-
|
|
617
|
+
} else {
|
|
618
|
+
console.log(c.red(c.bold(` Found ${totalIssues} issue${totalIssues > 1 ? "s" : ""}.
|
|
616
619
|
`)));
|
|
620
|
+
}
|
|
621
|
+
return results.hallucinated.length;
|
|
622
|
+
}
|
|
623
|
+
var issues = await runScan();
|
|
624
|
+
if (flags.watch) {
|
|
625
|
+
const CODE_EXTS2 = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
|
|
626
|
+
let debounce = null;
|
|
627
|
+
console.log(c.gray(` Watching for changes in ${targetDir}...
|
|
628
|
+
`));
|
|
629
|
+
fsWatch(targetDir, { recursive: true }, (_event, filename) => {
|
|
630
|
+
if (!filename) return;
|
|
631
|
+
const ext = path5.extname(filename);
|
|
632
|
+
if (!CODE_EXTS2.has(ext)) return;
|
|
633
|
+
if (filename.includes("node_modules") || filename.includes("dist")) return;
|
|
634
|
+
if (debounce) clearTimeout(debounce);
|
|
635
|
+
debounce = setTimeout(async () => {
|
|
636
|
+
console.clear();
|
|
637
|
+
await runScan();
|
|
638
|
+
console.log(c.gray(` Watching for changes...
|
|
639
|
+
`));
|
|
640
|
+
}, 300);
|
|
641
|
+
});
|
|
642
|
+
} else {
|
|
643
|
+
process.exit(issues > 0 ? 1 : 0);
|
|
617
644
|
}
|
|
618
|
-
process.exit(results.hallucinated.length > 0 ? 1 : 0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ghostimport",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Detects ghost imports in your code - imports that don't exist, hallucinated by AI coding tools.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"npm",
|
|
@@ -12,7 +12,13 @@
|
|
|
12
12
|
"imports",
|
|
13
13
|
"security",
|
|
14
14
|
"lint",
|
|
15
|
-
"ghost"
|
|
15
|
+
"ghost",
|
|
16
|
+
"import-validation",
|
|
17
|
+
"ai-safety",
|
|
18
|
+
"supply-chain",
|
|
19
|
+
"hallucination-detection",
|
|
20
|
+
"copilot",
|
|
21
|
+
"pre-commit"
|
|
16
22
|
],
|
|
17
23
|
"homepage": "https://github.com/FGuerreir0/ghostimport#readme",
|
|
18
24
|
"bugs": {
|
|
@@ -50,6 +56,7 @@
|
|
|
50
56
|
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
51
57
|
"test": "tsx tests/test.ts",
|
|
52
58
|
"dev": "tsx src/cli.ts",
|
|
59
|
+
"prepare": "git config core.hooksPath .githooks",
|
|
53
60
|
"prepublishOnly": "npm run build"
|
|
54
61
|
},
|
|
55
62
|
"devDependencies": {
|