How to Detect Install-Script Exfiltration
Block PhantomRaven-style dynamic install dependencies and obfuscated eval(atob(...)) payloads with the hasInstallScript and installScriptFetchesRemote conditions.
Overview
A package lifecycle script (preinstall, postinstall, prepare in npm;
setup.py extensions in pip; *.gemspec extensions in RubyGems;
Cargo.toml[package.build] + build.rs in Cargo; Composer lifecycle hooks)
runs on the developer’s machine the moment the package is installed. An
attacker who can publish a version of any dependency can use that hook to:
- Pull a second-stage payload from a remote URL (PhantomRaven pattern)
- Run
curl | sh,wget | bash,subprocess.Popen,child_process.exec - Decode a base64 blob with
eval(Buffer.from(…).toString())oratob(…) - Reach
~/.aws/credentials,~/.ssh/, CI secrets, environment variables
Chainsaw runs a static parse of the unpacked artifact before it resolves
and classifies the result into install_script_kind ∈
{none, present, fetches_remote, eval_encoded}. Two policy conditions turn
that classification into enforcement.
Prerequisites
- Admin or Manager role in Chainsaw
- Repositories proxying at least one of: npm, PyPI, RubyGems, Cargo, Composer
Step 1: Understand the Two Conditions
| Condition | When it fires |
|---|---|
hasInstallScript | Any lifecycle hook is present in the unpacked artifact manifest |
installScriptFetchesRemote | The hook body matches Chainsaw’s built-in fetch / eval detection regex |
The regex covers curl, wget, fetch(, https.get / http.get,
urllib(.request), requests.get, subprocess(.Popen|.run|.check_output|.call),
child_process.exec(Sync), os.system, eval(Buffer.from, atob(, long
base64-shaped blobs, and Function( constructors. A hit with only
obfuscation markers (no fetch) sets install_script_kind = eval_encoded;
the installScriptFetchesRemote condition matches both values.
Step 2: Roll Out in Monitor Mode
Create the first policy as a detection tripwire — not a block — so you see the inventory of packages in your organisation that already carry install scripts.
- Navigate to Policies → Create Policy
- Name:
Monitor — hasInstallScript - Condition:
hasInstallScript = true - Action: Warn (monitor)
- Scope: All repositories
After 7 days, check Violations and sort by package_metadata.install_script_kind.
You’ll see a long tail of present (legitimate — most native bindings ship a
postinstall gyp build), a short tail of fetches_remote, and occasional
eval_encoded entries that deserve a closer look.
Step 3: Block the Remote-Fetch Class
Once you’ve triaged the monitor-mode signal, promote fetches_remote to a
block:
- Name:
Block — installScriptFetchesRemote - Condition:
installScriptFetchesRemote = true - Action: Block
- Scope: Production repositories
- Precedence: High (above any general allowlist)
Step 4: Harden Against Obfuscation
Attackers commonly hide payloads as eval(Buffer.from(BASE64).toString())
or Function('…')(…). The eval_encoded classification catches these even
when no URL appears in the hook body. Extend your policy to treat
eval_encoded the same as fetches_remote:
conditions:
installScriptFetchesRemote: true # matches both fetches_remote and eval_encoded
Step 5: CLI Verification
The chainsaw CLI exposes the condition flag so you can dry-run a scan
before shipping policy changes:
chainsaw pkg scan npm/@some-org/some-package \
--version 1.4.2 \
--with-install-scripts
Output includes install_script_kind, matched patterns, and the hook body
excerpt that triggered the match.
Ecosystem Support
| Ecosystem | hasInstallScript | installScriptFetchesRemote |
|---|---|---|
| npm | ✅ package.json.scripts.{preinstall,install,postinstall,prepare,prepublish} | ✅ |
| PyPI | ✅ setup.py + pyproject.toml[tool.setuptools] | ✅ |
| RubyGems | ✅ *.gemspec extensions + require_paths | ✅ |
| Cargo | ✅ Cargo.toml[package.build] + build.rs | ✅ |
| Composer | ✅ composer.json.scripts | ✅ |
| Maven / Gradle / NuGet / Go / HuggingFace / Swift / CocoaPods / Docker / APT / Yum / DNF | ❌ (no lifecycle-script concept) | ❌ |