Research · 2026-07-29

Recovering a deleted npm package from a syscall trace

npm writes every file to disk as it unpacks a tarball. If you traced the install, you have the source, whether or not the package still exists.

A package called app-soda-layer came through our sandbox last week. Two hours and twenty-five minutes after publishing it, the author deleted it. GET /app-soda-layer/2.1.6 returns 404 now. It isn't in Datadog's malicious-packages dataset either, and I haven't found a public copy anywhere.

We still had the source. Not because anyone thought to save the tarball, but because of how npm installs things.

The source is already in the trace

When npm unpacks a tarball it writes each file out with write(2). If you're tracing the install, those writes are in your log, and the second argument to each one is the file contents.

Here is what that looks like. This is one line from the install:

111 1785185933.882998 write(191</tmp/detonate.Dk1VVL/node_modules/app-soda-layer/index.js>,
    "withFileTypes: true });\n  for (const entry of entries) {\n    const entryPath = ...",
    4149) = 4149

The file descriptor carries the path because the trace was taken with -y. The quoted string is the data. The integer after it is how many bytes were written.

So you don't need the package. You need the log of it being unpacked, and a regex.

Pulling it back out

Match every write whose fd path points inside the package directory, then concatenate the captured strings in the order they appear:

import gzip, re

WRITE = re.compile(r'write\(\d+<([^>]*app-soda-layer[^>]*)>, "((?:[^"\\]|\\.)*)"')

files = {}
with gzip.open("trace.gz", "rb") as fh:
    for raw in fh:
        m = WRITE.search(raw.decode("utf-8", "replace"))
        if m:
            files.setdefault(m.group(1), []).append(m.group(2))

The alternation in the string group, [^"\\]|\\., is what stops the match ending early on an escaped quote inside the file. Plenty of JavaScript contains \".

What you get out is still escaped the way strace escapes it: \n, \t, \", and octal like \303\274 for anything non-ASCII. Python will undo all of that for you:

def unescape(s):
    return s.encode("latin-1", "backslashreplace").decode("unicode_escape")

source = unescape("".join(files["/tmp/.../node_modules/app-soda-layer/index.js"]))

That reconstructed four files: index.js, test.js, package.json and the readme. Enough to read the whole thing and write it up.

The part that will bite you

strace truncates strings. The default is 32 bytes, and even with -s set high, a file large enough to be written in several chunks can have any one of those chunks clipped.

You can see it happen, which is the good news. A truncated string ends with "..., and the byte count on the line is the real one:

write(191</tmp/.../index.js>, "...\n  }\n\n  const driv"..., 4149 <unfinished ...>

That line says 4149 bytes went to disk. If what you captured from it is shorter, the difference is gone and you will not notice by reading the output. The result still looks like valid code.

So sum the declared counts and compare. For this file there were three writes, declaring 615, 4149 and 3784 bytes. That is 8548. We had 8495 characters. Fifty-three bytes short, and they were missing from a chunk boundary:

  const driv⟨53 bytes not captured⟩trim())
    .filter((line) => new RegExp("^[A-Z]:$", "").test(line));

A variable declaration and a split, in a Windows drive-enumeration helper. Nothing that changed the analysis. But we only know it didn't matter because we knew it was missing, and we only knew that from the arithmetic.

Do the arithmetic. A reconstruction you haven't checked is a reconstruction you shouldn't publish.

What this is and isn't good for

It recovers text. Anything binary in the tarball comes through as escaped bytes and is more work to reassemble, though the same approach holds.

It also only sees files npm actually wrote. That is normally everything in the package, but a file the installer skips is a file you won't have.

And the obvious one: this only works if you were already tracing. There's no way to go back and get a trace of an install you didn't run. Which is really an argument for keeping full traces rather than parsed summaries, and it's why we do. The parsed events would have told us app-soda-layer opened ~/.ssh/authorized_keys. They would not have given us the function that did it, the endpoints it called, or the rest of the code, and none of that is retrievable once the package is gone.

The write-up that came out of this one is here, if you want to see what the recovered source turned out to be.