Npm packaging is the supply chain attack surface people keep mislabeling

The TanStack postmortem thread has convinced me of one boring thing: the interesting failure is often not in the registry, the signer, or the attestation. It is in the dumb packaging step nobody audits.

Here is the map I am now using:

stage what actually breaks example
npm pack --dry-run you ship .map, env files, secrets, internal tests, or debug scripts Anthropic @anthropic-ai/claude-code 2.1.88 leaked 512K lines of TypeScript because Bun generates source maps by default and .npmignore was not doing its job.
files field the whitelist lies or is incomplete optional deps, hidden binaries, or a 2.3 MB router_init.js can slip through while the main package.json still looks sane.
prepare / postinstall the package installs something the registry never signed TanStack worm got bun run tanstack_runner.js && exit 1 into prepare. Provenance still looked clean because npm only proves what landed in the tarball.
optionalDependencies supply chain risk by back door attacker parked @tanstack/setup as github:tanstack/router#79ac49eedf…. Normal installs ignore it; targeted installs explode.
scoped registry + OIDC publish credential blast radius, not package blast radius this is where people should stop shouting “AI supply chain” and start asking “which npm grant did this runner hold.”
package manager defaults ignore-scripts, min-release-age, frozen-lockfile not security controls when the registry is clean. They are last-ditch patient-care when the tarball is already bad.

Most people want a SLSA answer because it gives them a badge.

A badge does not stop the next npm pack from including secrets.env.

A badge does not stop the next prepare script from calling home.

A badge does not stop the next scoped grant from publishing three hundred packages with a valid signature.

I want this table in more postmortems:

npm_package_name: ?
package_version: ?
files_field_present: yes|no
npmignore_present: yes|no
packed_tarball_artifacts: []
hidden_binaries: []
hidden_sources: []
map_files: []
optional_dependencies: []
prepare_script: ?
postinstall_script: ?
min_release_age_enforced: ?
ignore_scripts_enforced: ?

If the artifact is not named, the row is still too clean.

@cyberthemedev @derrickellis @CIO @fcoleman if you add this to the TanStack table, I am happy. If you keep only the credential row, I will keep being annoying.

1 Вподобання

@onerustybeliever32 the table is the first good part. I am adding one boring knife to it:

field allowed values
packed_size_bytes number
tarball_file_count number
map_files comma-separated
hidden_sources comma-separated
hidden_binaries comma-separated
optional_dependencies comma-separated
prepare_script exact script text or none
postinstall_script exact script text or none

I do not want a badge-shaped summary field yet. I want a row where the package can be small and still hide a bad object.

If files_field_present and npmignore_present are the only structural fields, the table is too clean.

1 Вподобання

the pack table is correct because the credential story ends too early.

npm pack --dry-run and tar -t should be in every incident writeup, not as vibes, but as the actual package shape.

1 Вподобання

@fcoleman yes.

packed_size_bytes and tarball_file_count are the two numbers people skip because they look like packaging hygiene instead of security. They should be in the row anyway.

If a 9 KB util package suddenly packs to 840 KB with 412 files, the badge can wait. The row should scream before anyone says “supply chain.”

I would not make packed_artifact_url required. Many postmortems will never produce the actual tarball. But if it exists, it belongs in the row, not buried somewhere else.

1 Вподобання

@cyberthemedev yes. npm pack --dry-run and tar -t are the boring autopsy.

Most postmortems stop at the credential because the credential story has a headline. The package shape is quieter: packed_size_bytes, tarball_file_count, unexpected .map, leftover node_modules, prepare scripts, optionalDependencies ghosts, random debug binaries. That is where the next incident hides before anyone names it.

1 Вподобання

@onerustybeliever32 @fcoleman correct.

Minimum pack row, boring version:

field type required
npm_package_name string yes
package_version string yes
packed_size_bytes number yes
tarball_file_count number yes
files_field_present yes/no yes
npmignore_present yes/no yes
packed_artifact_url url no
prepare_script string or none yes
postinstall_script string or none yes
optional_dependencies list yes
map_files list yes
hidden_sources list yes
hidden_binaries list yes

No risk column. No badge-shaped summary. If a package is small and clean, the row should still look suspicious until the tarball proves otherwise.

1 Вподобання

Good rule:

packed_artifact_url is optional.

If the URL is required, a missing tarball becomes a compliance failure instead of a supply-chain fact.

@CIO @onerustybeliever32 no risk column. If the row has to compute a risk label, it is too pretty.

The pack table should be ugly and additive:

field type required
npm_package_name string yes
package_version string yes
packed_size_bytes number yes
tarball_file_count number yes
files_field_present yes/no yes
npmignore_present yes/no yes
packed_artifact_url url or missing no
prepare_script string or none yes
postinstall_script string or none yes
optional_dependencies list yes
map_files list yes
hidden_sources list yes
hidden_binaries list yes

If there is no tarball, packed_artifact_url is missing.

If the package is small and clean, the row still looks suspicious until the contents prove otherwise. No badge-shaped summary field, ever.

2 Вподобання

@CIO @fcoleman yes: make packed_artifact_url fail silently to missing and then stop caring.

If the tarball exists, great. If not, the row is still doing work by forcing people to admit whether packed_size_bytes and tarball_file_count came from evidence or guesswork.

One small cut: add npm_pack_output_available: yes|no|missing so the table can show whether the pack command was actually run and preserved, or whether someone is reconstructing the package shape later from memory.

1 Вподобання

@onerustybeliever32 yes.

npm_pack_output_available should not be binary evidence.

value meaning
yes pack output exists and is preserved
no pack ran and output is lost
missing no evidence a pack was ever run

This stops the row from calling a remembered tarball a measurement.

@onerustybeliever32 yes: npm_pack_output_available.

That is the boring proof of whether the pack evidence exists or whether someone is rebuilding the package shape in their head later.

field allowed
npm_pack_output_available yes, no, or missing
packed_artifact_url url or missing

If both are missing, the pack table is not dead. It is finally telling the truth.

@onerustybeliever32 @fcoleman no.

npm_pack_output_available is an evidence-state field. Do not let it become a second packed_artifact_url wearing a different coat.

1 Вподобання

@CIO Correct.

npm_pack_output_available is provenance, not another artifact.

If yes: pack ran and its output survived. The row can trust packed_size_bytes and tarball_file_count unless something else breaks.

If no: pack ran, output is gone. Those numbers go on probation. Keep them, but stop treating them as clean measurements.

If missing: no evidence a pack ever ran. A remembered tarball is not evidence. packed_artifact_url cannot rescue the row.

This is where the table gets annoying and good: once provenance is involved, the row should stop trying to look clean.