Skip to content

Commit 65db98c

Browse files
committed
Release npm_ex 0.6.1
1 parent 3ca5292 commit 65db98c

9 files changed

Lines changed: 121 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 0.6.1
4+
5+
- Harden tarball extraction against path traversal and absolute-path entries
6+
- Preserve install-script metadata in `npm.lock`
7+
- Warn when dependencies declare ignored lifecycle scripts
8+
- Document that `npm_ex` does not run package lifecycle hooks automatically, mitigating install-time credential stealers
9+
310
## 0.6.0
411

512
- Move resolution modules under `NPM.Resolution`: `PackageResolver`, `Exports`, and `Conditional`

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Resolve, fetch, cache, and link npm packages directly from Mix.
1111

1212
```elixir
1313
def deps do
14-
[{:npm, "~> 0.4.0"}]
14+
[{:npm, "~> 0.6.1"}]
1515
end
1616
```
1717

@@ -107,6 +107,12 @@ mix npm.config
107107
9. Warns about unmet peer dependencies and deprecated packages
108108
10. Retries failed downloads with exponential backoff
109109

110+
## Supply-chain safety
111+
112+
`npm_ex` does not run package lifecycle hooks automatically. Packages with `preinstall`, `install`, `postinstall`, or `prepare` scripts are still installed, but their hooks are ignored and reported as warnings. Tarball paths are also validated before extraction so package contents cannot escape the cache directory.
113+
114+
This blocks install-time credential stealers that rely on postinstall hooks reading files like `.env` and exfiltrating them during dependency installation.
115+
110116
## Why `npm.lock` instead of `package-lock.json`?
111117

112118
`npm_ex` is not npm, so it keeps its own lockfile. `package.json` is the shared manifest; `npm.lock` is the reproducibility file for the `npm_ex` installer.

lib/npm.ex

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ defmodule NPM do
318318
:ok ->
319319
ms = div(link_us, 1000)
320320
Mix.shell().info(NPM.DepsOutput.format_summary(map_size(lockfile), ms))
321+
warn_ignored_install_scripts(lockfile)
321322
Mix.shell().info(NPM.DepsOutput.format_lockfile(lockfile))
322323
:ok
323324

@@ -338,7 +339,8 @@ defmodule NPM do
338339
integrity: info.dist.integrity,
339340
tarball: info.dist.tarball,
340341
dependencies: info.dependencies,
341-
optional_dependencies: info.optional_dependencies
342+
optional_dependencies: info.optional_dependencies,
343+
has_install_script: info.has_install_script
342344
}}
343345
end
344346

@@ -364,7 +366,8 @@ defmodule NPM do
364366
integrity: info.dist.integrity,
365367
tarball: info.dist.tarball,
366368
dependencies: info.dependencies,
367-
optional_dependencies: Map.get(info, :optional_dependencies, %{})
369+
optional_dependencies: Map.get(info, :optional_dependencies, %{}),
370+
has_install_script: Map.get(info, :has_install_script, false)
368371
})
369372

370373
:error ->
@@ -404,6 +407,20 @@ defmodule NPM do
404407
end)
405408
end
406409

410+
defp warn_ignored_install_scripts(lockfile) do
411+
packages =
412+
lockfile
413+
|> Enum.filter(fn {_name, entry} -> Map.get(entry, :has_install_script, false) end)
414+
|> Enum.map_join(", ", fn {name, entry} -> "#{name}@#{entry.version}" end)
415+
416+
if packages != "" do
417+
Mix.shell().info(
418+
"npm WARN ignored lifecycle scripts for #{packages}. " <>
419+
"npm_ex never runs preinstall/install/postinstall hooks automatically."
420+
)
421+
end
422+
end
423+
407424
defp check_deprecated(name, version, info) do
408425
case Map.get(info, :deprecated) do
409426
nil ->

lib/npm/lockfile.ex

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ defmodule NPM.Lockfile do
1313
integrity: String.t(),
1414
tarball: String.t(),
1515
dependencies: %{String.t() => String.t()},
16-
optional_dependencies: %{String.t() => String.t()}
16+
optional_dependencies: %{String.t() => String.t()},
17+
has_install_script: boolean()
1718
}
1819

1920
@type t :: %{String.t() => entry()}
@@ -54,7 +55,8 @@ defmodule NPM.Lockfile do
5455
integrity: Map.get(info, "integrity", ""),
5556
tarball: Map.get(info, "tarball", ""),
5657
dependencies: Map.get(info, "dependencies", %{}),
57-
optional_dependencies: Map.get(info, "optional_dependencies", %{})
58+
optional_dependencies: Map.get(info, "optional_dependencies", %{}),
59+
has_install_script: Map.get(info, "has_install_script", false)
5860
}}
5961
end
6062
end
@@ -109,7 +111,8 @@ defmodule NPM.Lockfile do
109111
"integrity" => entry.integrity,
110112
"tarball" => entry.tarball,
111113
"dependencies" => entry.dependencies,
112-
"optional_dependencies" => Map.get(entry, :optional_dependencies, %{})
114+
"optional_dependencies" => Map.get(entry, :optional_dependencies, %{}),
115+
"has_install_script" => Map.get(entry, :has_install_script, false)
113116
}}
114117
end
115118
end

lib/npm/script_install.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ defmodule NPM.ScriptInstall do
7878
integrity: info.dist.integrity,
7979
tarball: info.dist.tarball,
8080
dependencies: info.dependencies,
81-
optional_dependencies: info.optional_dependencies
81+
optional_dependencies: info.optional_dependencies,
82+
has_install_script: info.has_install_script
8283
}}
8384
end
8485
end

lib/npm/tarball.ex

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,18 +60,56 @@ defmodule NPM.Tarball do
6060

6161
case :erl_tar.extract({:binary, tgz_data}, [:compressed, :memory]) do
6262
{:ok, entries} ->
63-
Enum.each(entries, &write_entry(&1, dest_dir))
64-
{:ok, length(entries)}
63+
with {:ok, files} <- safe_entries(entries, dest_dir) do
64+
Enum.each(files, &write_entry/1)
65+
{:ok, length(files)}
66+
end
6567

6668
{:error, reason} ->
6769
{:error, {:extract, reason}}
6870
end
6971
end
7072

71-
defp write_entry({path, content}, dest_dir) do
72-
rel_path = strip_prefix(to_string(path))
73-
full_path = Path.join(dest_dir, rel_path)
73+
defp safe_entries(entries, dest_dir) do
74+
Enum.reduce_while(entries, {:ok, []}, fn {path, content}, {:ok, acc} ->
75+
original_path = to_string(path)
76+
rel_path = strip_prefix(original_path)
77+
78+
case safe_path(dest_dir, rel_path) do
79+
{:ok, full_path} -> {:cont, {:ok, [{full_path, content} | acc]}}
80+
{:error, reason} -> {:halt, {:error, {reason, original_path}}}
81+
end
82+
end)
83+
|> case do
84+
{:ok, files} -> {:ok, Enum.reverse(files)}
85+
error -> error
86+
end
87+
end
88+
89+
defp safe_path(dest_dir, rel_path) do
90+
dest = Path.expand(dest_dir)
91+
full_path = Path.expand(rel_path, dest)
92+
93+
cond do
94+
rel_path in ["", "."] ->
95+
{:error, :unsafe_path}
96+
97+
unsafe_segments?(Path.split(rel_path)) ->
98+
{:error, :unsafe_path}
99+
100+
not inside_dir?(full_path, dest) ->
101+
{:error, :unsafe_path}
102+
103+
true ->
104+
{:ok, full_path}
105+
end
106+
end
107+
108+
defp unsafe_segments?(segments), do: Enum.any?(segments, &(&1 in ["..", ""]))
109+
110+
defp inside_dir?(path, dir), do: path == dir or String.starts_with?(path, dir <> "/")
74111

112+
defp write_entry({full_path, content}) do
75113
full_path |> Path.dirname() |> File.mkdir_p!()
76114
File.write!(full_path, content)
77115
end

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule NPM.MixProject do
22
use Mix.Project
33

4-
@version "0.6.0"
4+
@version "0.6.1"
55
@source_url "https://github.com/elixir-volt/npm_ex"
66

77
def project do

test/npm/lockfile_test.exs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,26 @@ defmodule NPM.LockfileTest do
2828
assert read_back["lodash"].integrity == "sha512-abc123=="
2929
assert read_back["lodash"].tarball =~ "lodash-4.17.21.tgz"
3030
assert read_back["lodash"].dependencies == %{}
31+
assert read_back["lodash"].has_install_script == false
32+
end
33+
34+
@tag :tmp_dir
35+
test "preserves install script metadata", %{tmp_dir: dir} do
36+
path = Path.join(dir, "npm.lock")
37+
38+
lockfile = %{
39+
"native-pkg" => %{
40+
version: "1.0.0",
41+
integrity: "sha512-abc==",
42+
tarball: "https://registry.npmjs.org/native-pkg/-/native-pkg-1.0.0.tgz",
43+
dependencies: %{},
44+
has_install_script: true
45+
}
46+
}
47+
48+
assert :ok = NPM.Lockfile.write(lockfile, path)
49+
assert {:ok, read_back} = NPM.Lockfile.read(path)
50+
assert read_back["native-pkg"].has_install_script == true
3151
end
3252

3353
@tag :tmp_dir

test/npm/tarball_test.exs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,22 @@ defmodule NPM.TarballTest do
8585
assert {:ok, 1} = NPM.Tarball.extract(tgz, dir)
8686
assert File.read!(Path.join(dir, "index.js")) == "no prefix"
8787
end
88+
89+
@tag :tmp_dir
90+
test "rejects paths escaping the destination", %{tmp_dir: dir} do
91+
tgz = create_test_tgz(%{"package/../evil.txt" => "owned"})
92+
93+
assert {:error, {:unsafe_path, "package/../evil.txt"}} = NPM.Tarball.extract(tgz, dir)
94+
refute File.exists?(Path.join(Path.dirname(dir), "evil.txt"))
95+
end
96+
97+
@tag :tmp_dir
98+
test "rejects absolute paths", %{tmp_dir: dir} do
99+
tgz = create_test_tgz(%{"/tmp/npm-ex-evil.txt" => "owned"})
100+
101+
assert {:error, {:unsafe_path, "/tmp/npm-ex-evil.txt"}} = NPM.Tarball.extract(tgz, dir)
102+
refute File.exists?("/tmp/npm-ex-evil.txt")
103+
end
88104
end
89105

90106
describe "Tarball.extract edge cases" do

0 commit comments

Comments
 (0)