phi runs three layers on every package: pattern detectors (regex), AST-validated detectors (parsed JS), and a known-vulnerability check against OSV. Hits from any layer add to the same risk score.
| Severity | Points |
|---|---|
| CRITICAL | +35 |
| HIGH | +20 |
| MODERATE | +10 |
| LOW | +5 |
Score is summed per package, capped at 100. Detections are deduplicated per detector — a noisy codebase doesn't artificially inflate the score.
| Score | Verdict | Behavior |
|---|---|---|
| 0 – 19 | SAFE | Install silently. |
| 20 – 59 | REVIEW | Prompt the developer (or fail in --json / --frozen mode). |
| 60 – 100 | BLOCKED | Refuse to install, write report, exit non-zero. |
eval + new Function in pino's logger) lands in REVIEW for the user to decide. Real malware combines two CRITICAL signals (eval + cred theft = 70) and still trips.Direct execution of arbitrary code: eval(...), child_process.exec/spawn(...).
AST-validated — phi parses .js / .cjs / .mjs files with goja and only fires on real CallExpressions. References inside string literals, comments, or identifier names are suppressed. eslint's source contains eval in regex patterns; AST validation correctly skips those.
String-to-function compilation: new Function(...).
Legitimately used by validator generators (zod, ajv), JSON serializers (fast-json-stringify), route compilers (find-my-way), formatters (prettier), stack-trace utilities (depd). Demoted to HIGH so a single hit lands in REVIEW; combined with a CRITICAL or another HIGH it can still escalate.
Techniques used to hide malicious payloads.
\x07\x07\x07…) are skipped because real obfuscators emit varied bytes.Buffer.from("<40+ chars of base64>", "base64")String.fromCharCode(N, N, N, N+) with at least 4 numeric argsatob("<40+ chars of base64>")Access to API keys, tokens, or credential files.
Smart matcher — knows the package's normalized name and silently skips reads of the package's own env vars. resend reading process.env.RESEND_API_KEY doesn't fire; an unrelated package reading AWS_SECRET_ACCESS_KEY does. Allowlist of well-known third-party credentials includes AWS, Azure, GCP, GitHub, GitLab, npm, Heroku, Docker, Slack, Discord, Stripe, Twilio, Mailgun, Sendgrid.
File-based credential references — .npmrc, .netrc, id_rsa, id_ed25519 — fire unconditionally.
Lifecycle scripts (preinstall / install / postinstall) that pipe remote code into a shell.
Smart matcher — only inspects scripts.{preinstall,install,postinstall} of package.json. Test scripts, prepublish hooks, and build scripts that happen to use node -e or curl | sh don't fire. Real-world false positive on ljharb's utility packages eliminated.
Cryptocurrency mining APIs and pool connections: CoinHive, Monero/XMR, stratum+tcp:// URLs, CryptoNight references.
Cryptocurrency wallet access or transfer patterns: web3.eth.sendTransaction, ethers.Wallet instantiation, drainTokens / drainWallet function names.
Patterns used to open a remote shell back to an attacker: /bin/bash -i, /dev/tcp/, mkfifo, nc -e /bin/.
Outbound calls to known exfiltration services or hidden domains. Narrowed on purpose:
.onion URLs (Tor)Generic fetch(...), axios.X(...), and require('http') patterns produce too many false positives on legitimate API clients, so they're not flagged here.
Package name within Levenshtein distance == 1 of a popular package (lodash, express, axios, react, vue, …). Distance-2 was tried originally but produced false positives on legitimate short names like fecha matching mocha.
Reads of OS-level sensitive paths: /etc/passwd, /etc/shadow, .aws/credentials, .kube/config, .docker/config.json.
Beyond the static detectors, phi queries every resolved (name, version) against the OSV database, which aggregates GHSA, OpenSSF malicious-packages, and CVE feeds. Hits append to the same risk score using their advisory severity:
| OSV severity | phi points |
|---|---|
| CRITICAL | +35 |
| HIGH | +20 |
| MODERATE | +10 |
| LOW / UNKNOWN | +5 |
Disable with --no-advisories for offline use. Network failures are non-fatal — phi prints a warning and proceeds without advisory data.
In internal/analyzer/detectors.go, append to the detectors slice:
{
name: "Your Detector",
description: "What it catches",
severity: SeverityHigh,
patterns: mustCompile(
`your\s+regex\s+here`,
),
}
For context-aware detection (needs the package name or a parsed AST), set a matcher function instead of patterns. The scoring and UI layers pick up new detectors automatically. Tests live in internal/analyzer/analyzer_test.go.