phi runs three layers on every dependency: pattern detectors (regex), source-level detectors (AST-validated JS or Go), and a known-vulnerability check against OSV. Hits from any layer add to the same risk score — whether the package came from npm or Go's module proxy.
| 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.
The original detector suite, focused on Node's threat surface: arbitrary code execution at install / import time, obfuscation, credential theft, network exfiltration, typosquats, and the rest. Each fires AST-validated where possible to keep false positives low. Go-specific detectors are documented below in § 03.G.
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.
discord.js bundles an internal
_eval(script) method documented as "calls eval() on a
script with the client as this". Phi's AST walker
correctly identifies this as a real call expression and scores it
CRITICAL — but the verdict lands at REVIEW (score 35), not BLOCKED. A
library you trust can ship through with --force; the
phi-report.json audit trail records the override.
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") —
with WASM-magic exception (AGFzbQ prefix is
unambiguously a WebAssembly module, not an obfuscated payload).
undici's embedded llhttp parser was the false positive that drove
the exception.
String.fromCharCode(N, N, N, N+) with at least 4
numeric args
atob("<40+ chars of base64>")flatmap-stream as a
sub-dependency, which contained an obfuscated payload targeting users
with the Copay Bitcoin wallet in their dependency tree. Phi's
hex-escape detector with diversity check matches the exact obfuscation
shape used in that payload. ~2M weekly downloads at the time of
compromise.
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.
~/.npmrc and exfiltrated the publish
token to attacker infrastructure — laying the groundwork for further
package takeovers. Phi's file-based credential reference detector
fires unconditionally on .npmrc reads.
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.
--allow-scripts esbuild,sharp opts in by package name;
everything else stays inert.
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.
ethers.Wallet / web3.eth APIs phi flags
here.
Patterns used to open a remote shell back to an attacker:
/bin/bash -i, /dev/tcp/,
mkfifo, nc -e /bin/.
Most npm-distributed reverse shells get their hands dirty in two places — the literal shell-trigger string AND a lifecycle script that fires it. Phi flags the string regardless of whether it's wired up; the install-script detector (§ 05 above) catches the trigger. Two layers of the same attack land in BLOCKED easily.
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.
cross-env that exfiltrated environment variables on first
require(). Single-character difference from a package
with millions of weekly downloads. Phi's distance-1 check catches this
exact shape: insertion, deletion, or substitution of one character
against a curated list of high-traffic names.
Reads of OS-level sensitive paths: /etc/passwd,
/etc/shadow, .aws/credentials,
.kube/config, .docker/config.json.
Pattern set is intentionally short — these are paths a package has no business reading, full stop. Keeping the set narrow keeps false positives at zero. Credential-harvester npm packages discovered in 2022-2024 typosquat campaigns commonly hit the AWS credentials path before exfiltrating; that's what this detector exists to catch.
Combined-flow detector — fires when the same source file BOTH reads a
third-party credential AND makes an outbound HTTP call
(fetch, axios, got,
http.request, XMLHttpRequest,
navigator.sendBeacon, .post(...), etc.).
Smart matcher with a token-to-canonical-host allowlist. Reading
GITHUB_TOKEN and posting to
api.github.com is silent (octokit and similar clients);
the same token going to evil.example.com fires.
Subdomain-aware — api.github.com matches the canonical
github.com.
File-based reads (.npmrc, id_rsa,
.aws/credentials) bypass the host allowlist entirely —
there's no legitimate destination for someone's SSH key. Combined with
the existing Credential Theft detector this pushes real exfiltration
into BLOCKED territory.
process.env.*, POST to an attacker-controlled domain. The
combined-flow detector matches the shape itself, with the
canonical-host allowlist suppressing legit API clients (octokit, the
AWS SDK, Stripe, Twilio, Sendgrid).
Symbols and primitives that don't belong in npm packages. Direct response to recent QLNX-style RAT tradecraft (PAM backdoors, eBPF rootkits, fileless execution).
pam_authenticate,
pam_open_session, pam_sm_authenticate,
pam_sm_setcred, pam_start,
pam_end, libpam.so
BPF_PROG_LOAD,
BPF_MAP_CREATE, bpf_load_program,
bpf_obj_pin, bpf_prog_attach,
perf_event_open
init_module(...),
finit_module(...), delete_module(...),
create_module(...)
/etc/ld.so.preload writes
Conservative word-bounded regex. False positive rate is essentially zero in practice — these symbols simply don't appear in legitimate Node packages.
LD_PRELOAD writes to
maintain covert access — 58 documented remote commands across the
toolchain. Phi flags the symbols at install time, before any
of them have a chance to execute. The class of threat exists because
devs have npm tokens, AWS keys, and CI/CD credentials on their boxes;
phi cuts the npm vector.
When phi runs in Go mode, six per-module detectors plus one
project-level detector fire against each fetched module's source
tree. Same scoring ladder (CRITICAL/HIGH/MEDIUM/LOW), same
verdict thresholds, same phi.lock audit trail —
but tuned for Go's threat surface: init() at
program start, cgo, build-tag-gated payloads, and
//go:generate directives.
A file uses cgo (import "C") AND calls
os/exec.Command or syscall.Exec in the
same package. The combination means "I can run unaudited C
code AND I can spawn arbitrary processes" — the highest
severity Go signal phi flags.
init() function body contains calls into
net. or net/http.. Go runs every
imported package's init() automatically at
program start — outbound HTTP/DNS in init means
merely importing the module is enough to phone home.
init() writes to the filesystem outside
os.TempDir() — os.Create,
os.OpenFile, os.WriteFile,
os.MkdirAll, etc. Side effects on import are
the Go-shaped equivalent of npm's postinstall
script.
A //go:generate directive shells out to
curl, wget, or contains an
http:// / https:// URL.
go generate isn't part of go build,
but CI pipelines commonly run it — giving the module
a foothold to fetch and run arbitrary remote code.
A file has a restrictive //go:build directive
(three+ tokens or a negation like !debug /
!test) AND contains
net. / net/http. /
os/exec. / syscall. calls.
The pattern of "only compile this code under these specific
platforms / build flavors AND it does dangerous things" is a
hint of a platform-targeted payload that won't fire in CI's
default build.
File imports the unsafe package. Not malicious on
its own — performance-sensitive code legitimately uses
unsafe.Pointer for raw pointer arithmetic. Phi
surfaces it as a signal because unsafe is
over-represented in supply-chain incidents: it lets an
attacker bypass type safety and memory protections.
The user's own go.mod has a replace
directive pointing to a different VCS organization than the
original. e.g. replace github.com/foo/bar =>
github.com/quux/bar — same-name module, different
owner. Most replaces are legitimate (local forks for
development, organization-internal mirrors), but cross-org
replaces deserve a deliberate "yes, I trust this fork" from
a reviewer.
This detector fires at the project level, not per-module —
phi reads your go.mod's replace block
during install and audit, not the upstream module's own
go.mod.
archive/zip + the Go parser
(go/parser with SkipObjectResolution
for speed). Per-file 5 MiB cap, per-module 10-second wallclock
timeout, panic-recovery on every file so a crafted AST
can't crash phi. The analyzer uses
golang.org/x/mod/zip's
CheckZip equivalent to reject zips with
path-traversal entries or wrong-prefix file names before any
file is read.
Beyond the verdict-bearing detectors, phi surfaces small info-only notes for packages worth a second look. They don't affect the score and they don't block install — they're a nudge.
Currently shipped: deprecation guidance for packages with safer successors:
| Package | Replacement |
|---|---|
vm2 |
isolated-vm (12 critical sandbox-escape CVEs
through CVE-2026-44009; the proxy-sandbox architecture cannot be
fully secured)
|
request / request-promise |
undici, axios, or
got (request was discontinued 2020)
|
node-uuid |
uuid (renamed) |
tslint |
eslint + @typescript-eslint |
bower |
npm / yarn / pnpm |
node-sass |
sass (Dart Sass) |
babel-eslint |
@babel/eslint-parser (renamed) |
left-pad, is-array |
stdlib (String.prototype.padStart,
Array.isArray)
|
Notices appear under the report card as
note deprecated — <message> and ship in
phi-report.json under each package's
notices[] array.
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.