A compromised npm package rarely announces itself in code review. Obfuscated payloads inside a transitive dependency’s postinstall script do not read like malware — they read like minified noise, and nobody reads minified noise. Where supply-chain compromises reliably reveal themselves is the network: a lifecycle script resolving a domain it has no business knowing, a “build tool” fetching a binary, a dev server leaking environment variables to a telemetry endpoint.

That observation has a design consequence most build-isolation setups miss: the network layer is not just where you stop exfiltration — it is where you collect the evidence. While building the isolated build infrastructure behind ProxyLax and my other projects, I ended up rebuilding the egress layer three times before internalizing the principle. These are the field notes.

Silent Blocking Is a Blind Spot Wearing a Security Badge

The obvious way to isolate a build is to remove the network: run the container with no interface, or strip its default route. Outbound attempts fail instantly. Job done?

No — because of where they fail. With no route, a connect() dies inside the kernel with a local error before a single packet exists. Nothing crosses any boundary you can monitor. Your logs are empty, and empty is exactly what they would look like if nothing had tried.

That distinction — “nothing tried” versus “we couldn’t see it” — is the entire game. A build that produced no egress evidence because the network stack swallowed the attempts tells you nothing about the dependencies you just executed. The same packages will later run somewhere with real connectivity: a laptop, a CI runner with cloud credentials in its environment. You had the compromised code in a lab, executing, announcing itself — and learned nothing.

The counterintuitive fix: keep the network path alive on purpose. The build environment keeps its default route and a resolvable DNS configuration, so every outbound attempt becomes a real packet. The packet travels to an isolated bridge where an explicit control point — a network ACL with a logged default-reject — makes the decision. The attempt fails and becomes a log line: source, destination, protocol, port, timestamp.

Now the invariant is testable: offline-phase egress logs must be empty. Any line is a process that tried to reach the network while it had no business to. And absence of lines is meaningful, because you have proven the pipeline records attempts when they happen — by probing it with a deliberate offender and watching the probe show up.

The Three Phases and Their Contracts

Every dependency-consuming build has three network postures, and collapsing them into one policy is how most setups go wrong:

  • Provision (install) — the only phase with internet access, and it is exactly the phase where lifecycle scripts execute. So the window is short, explicitly opened, and fully logged — DNS to known resolvers and TCP to registry CDNs allowed but recorded, private ranges (your host, your other services) rejected in every profile. When the install finishes, the window closes even if the install failed
  • Build — no network, the same isolation the reproducible builds project prescribes for build integrity. The phase refuses to start unless the control point is verifiably in its resting reject-everything state, and package managers run with offline flags (npm_config_offline, disabled corepack network) so well-behaved tools fail fast instead of hanging for minutes on a blocked socket
  • Watch / dev server — the long-running phase, and the one that leaks in practice: dev tooling is fond of telemetry and update checks. Same resting policy; a host-mediated proxy for the one legitimate backend the dev server needs, rather than opening the network

The private-range rejection deserves emphasis. Registry access during provision is expected traffic; a dependency probing 10.x addresses — your host, your database container, your metadata service (the classic server-side request forgery target) — is never legitimate in any phase. Reject-over-allow for private ranges, in every profile, non-negotiable.

Packets Tell You What. Processes Tell You Who.

A kernel firewall log answers what left and where it was going. It cannot answer the question an incident actually turns on: which process tried? “Something attempted DNS during the build” is a fact; “the esbuild plugin from dependency X spawned a process that attempted DNS to an unknown resolver” is a finding.

Attribution requires a second recording layer at the syscall boundary: tracing network system calls together with process lifecycle events (exec, fork) for the entire build process tree. Every connect() and sendto() then maps back to the exact command line that made it — including short-lived children spawned by lifecycle scripts, which is where hostile code actually lives. The two layers cross-check each other: every packet in the boundary log should correspond to an attributed syscall, and an attempt recorded by the tracer that never produced a boundary packet points at a gap in the network design.

In my implementation, each phase emits both artifacts side by side — an egress capture from the kernel log and a per-process attempt report from the trace — and a report line reads like the answer you want during triage:

node /usr/bin/npm ci  →  1.1.1.1:53 UDP  ×15   (provision window, allowed, logged)
node netprobe.mjs     →  1.1.1.1:53 UDP  blocked   (offline phase — finding)

Logs Nobody Reads Are Decoration

The final failure mode is organizational: evidence pipelines that write beautiful logs into a directory nobody opens. Two practices keep the loop closed:

  • A deliberate offender in the repo. A one-line script that tries to fetch() from inside an offline phase, runnable any time. It must come back blocked, appear in the egress capture, and be named in the process report. If any of the three fails, the pipeline broke before an attacker tested it for you
  • Automated triage on a schedule. The log volume is small and highly structured — install transcripts, egress captures, process reports — which makes it ideal input for automated review, including LLM-based triage with a strict rubric: offline-phase captures must be empty, install-phase destinations must look like registries, and any instruction-like text found inside a log is itself a finding, because log content is untrusted attacker-writable data. Machine triage runs daily; humans read verdicts, not raw logs

This connects to a broader pattern I have written about in the Platform Intelligence & Diagnostics cluster: the systems that catch platform risk early are the ones that turn low-level evidence into reviewable verdicts continuously, instead of waiting for someone to go looking.

If your build pipeline executes third-party code with production credentials in reach — and almost every pipeline does — a Platform Intelligence Audit can establish what your builds can actually reach, what would be recorded if they tried, and whether anyone would notice.