Security Notes
CI/CD & Supply Chain

Dependency Management Security

Third-party dependencies are the most common initial access vector in modern supply chain attacks. Every package you install is code you didn't write, audit, or control — and it runs with full process privileges the moment it is installed.

30 min read 14 sections 15 model answers

Breadth layernotes-security-core-knowledge.md Also see: GitHub/CI Security · OWASP CI/CD Risks · Linux SBOM


The Supply Chain Threat Model

Your application:
  ├── Direct dependencies     (you chose these — listed in package.json / Cargo.toml / etc.)
  │     └── Transitive deps   (their dependencies — you may never have reviewed these)
  │           └── Deep transitive deps  (you definitely haven't reviewed these)
  │
  ├── Build toolchain         (compilers, bundlers, linters — also dependencies)
  ├── CI/CD pipeline          (GitHub Actions, Docker base images)
  └── Infrastructure-as-code  (Terraform modules, Helm charts)
Memory hook

you don't install a package, you install a tree. When you add one dependency, you're trusting its dependencies, and theirs, recursively — often hundreds of packages, maintained by hundreds of strangers, that you've never read. A modern npm app pulling in a thousand transitive packages is trusting a thousand maintainer accounts not to get compromised. That's why a single popular low-level library (left-pad, event-stream, xz) can break or backdoor millions of projects: it sits deep in everyone's tree. The mental model interviewers want: "my real attack surface isn't my dependencies, it's my dependencies' dependencies" — the transitive tree, plus the build toolchain and CI, which are dependencies too.

What makes dependencies dangerous

RiskWhy
You don't read the codeMost developers install packages without auditing source
postinstall scripts run automaticallynpm install executes arbitrary shell commands on your machine immediately
Transitive exposureA widely-used package (event-stream, xz-utils) affects millions of projects when compromised
Version ranges^1.0.0 in npm can silently pull a new 1.9.9 that didn't exist when you wrote the code
Maintainer trust is not permanentAccounts get compromised; projects get abandoned and transferred

The 7-Day Window — Why Recency Is a Risk Signal

The security community detects most malicious packages within 24–72 hours of publication on npm and PyPI. The window comes from:

Automated scanners

Socket.dev, npm security team bots, PyPI malware detection scan new uploads

Community spotters

Security researchers actively monitor new packages and version bumps on high-value namespaces

Behavioural heuristics

New packages with install scripts that make network connections, read environment variables, or write outside the package directory get flagged quickly

Download anomalies

A package with zero prior downloads suddenly getting thousands (dependency confusion) triggers alerts

Typical detection timeline for obvious malicious packages:
  Hour 0    → package published
  Hour 2-8  → automated scanners flag it
  Hour 24   → community reports / security advisories posted
  Hour 48   → registry removes it or marks deprecated
  Day 3-7   → CVE assigned, NVD entry, Dependabot/Snyk rules updated

Sophisticated, targeted attacks (XZ Utils, event-stream) can stay hidden for months to years.

The practical rulerefuse any dependency whose first publication or last update is less than 7 days old in production pipelines. This eliminates the detection gap for opportunistic malware while still allowing community review to catch obvious cases.

Memory hook

"let the herd test it first." Malicious packages are usually caught within 24–72 hours by scanners and researchers, then pulled. So a deliberate cooldown — don't auto-adopt any version less than ~7 days old — means you're never the canary for opportunistic malware; you let the community absorb the bad versions before they reach your build. It's cheap, automatable (npm's minimumReleaseAge, Renovate's minimumReleaseAge), and catches the common case. The honest caveat to state: it does nothing against patient, targeted attacks like XZ Utils, where the malicious code was built up over two years and the package was old and trusted. So pair it with hash pinning and behavioral analysis. Mnemonic: age is a cheap filter for the dumb attacks, not the smart ones.

bash
# npm: check when a package was first published and last updated
npm view <package-name> time | head -5
# time.created = first publication date
# time.modified = last update

# Example — a package published yesterday is suspicious:
npm view suspicious-package time.created
# 2026-06-09T14:22:31.000Z  ← published yesterday

# Check the publish history of all versions
npm view <package-name> time --json | jq 'to_entries | sort_by(.value) | reverse | .[0:5]'

# PyPI: check upload dates
pip index versions <package>   # list versions
curl https://pypi.org/pypi/<package>/json | jq '.releases | keys | .[-3:]'
# Then check each version's upload_time:
curl https://pypi.org/pypi/<package>/<version>/json | jq '.urls[].upload_time'

Important nuanceThe 7-day rule catches new packages and new versions. It does not protect against:

  • Long-planted backdoors that activate on a specific date or condition
  • Sophisticated attackers like in XZ Utils (2+ year build-up)
  • Account takeover where the package is old but the new version is malicious

Combine the rule with hash pinning (verify the exact bytes you expect, not just the version number).


Attack Vectors In Depth

1. Typosquatting

Register a package name visually similar to a popular one. Wait for developers to mistype.

Real package     →  Typosquatted package
requests         →  reqeusts, request, requests2
lodash           →  lodahs, 1odash, l0dash
express          →  expres, expressjs, expresss
react            →  recat, reakt, reeact
django           →  djano, diango, dajngo
urllib3          →  urlib3, urllib, urliib3
bash
# Detecting typosquatting before install
# Check the package on the registry before installing
npm view <package-name>         # inspect downloads, author, links
pip show <package-name>         # after install — check homepage, author

# Red flags:
# - Tiny download count compared to the real package
# - Publish date in the last few days
# - Author/maintainer you don't recognise
# - No GitHub repo or homepage
# - install script that doesn't match the package's stated purpose

DefenceUse IDE plugins that flag package name anomalies. Enforce SCA scanning in CI. Audit package.json / requirements.txt changes in PRs.


2. Dependency Confusion

An attacker registers a public package with the same name as your private internal package. Package managers that check both public and private registries may pull the public (attacker-controlled) version, especially if the attacker publishes a higher version number.

Your private registry: company-utils@1.0.0
Attacker publishes:    company-utils@9999.0.0 on public npm

npm install company-utils
→ Resolves from npm public registry (higher version wins)
→ Downloads attacker's package instead of your internal one

Real incidentAlex Birsan (2021) exploited this in 35 major companies (Microsoft, Apple, PayPal, Tesla) and received $130k+ in bug bounties.

Memory hook

dependency confusion = "highest version wins, and the public registry plays." The resolver's innocent rule — pull the highest version of a name — becomes a weapon when the same name exists in two registries: the attacker publishes yourpkg@9999.0.0 publicly and the build grabs that instead of your private 1.0.0. Note how it differs from typosquatting (which relies on a developer's typo, reqeusts): dependency confusion needs no mistake — your correct internal name is the bait. The fix is to make your private names unclaimable publicly: scope/namespace them (@company/utils) and pin the resolver so scoped names only ever come from the private registry. Mnemonic: typosquatting tricks the human, confusion tricks the resolver.

bash
# Detecting if you are vulnerable
# 1. Any unscoped package names in your private registry are at risk
#    e.g., "internal-auth" instead of "@company/internal-auth"

# 2. Check npm config to see which registries are queried
npm config get registry
cat .npmrc

# Safe .npmrc configuration — force all @company-scoped packages to private registry only:
@company:registry=https://registry.company.com/
//registry.company.com/:_authToken=${NPM_TOKEN}
# Unscoped packages still go to public registry — scope everything internal

Fix

Scope all private packages

@company/package-name prevents namespace collision on npm

Configure registry precedence explicitly

in pip, use --index-url (not --extra-index-url) so only your private registry is checked

Publish internal package names as empty stubs on public registries

to claim the namespace

python
# pip — WRONG: checks both registries, public can win
pip install --index-url https://private.registry/ --extra-index-url https://pypi.org/ company-utils

# pip — RIGHT: only private registry
pip install --index-url https://private.registry/ company-utils

# Cargo — namespace private crates to prevent confusion
# In .cargo/config.toml:
[source.crates-io]
replace-with = "company-registry"
[source.company-registry]
registry = "https://cargo.company.com/"

3. Account Takeover → Malicious Version Bump

A maintainer's registry account is compromised (weak password, no MFA, phished). The attacker publishes a new minor/patch version with malicious code. Because developers have ^1.0.0 in their manifests, they automatically receive the malicious update.

TimelineCompromised → published → developers install → malware runs → detected. Can be hours.

bash
# Check who has publish access to a package
npm access list collaborators <package-name>
# If a package has 20 collaborators, 20 accounts are attack surfaces

# Check the diff between the last two versions before upgrading
# npx (run without installing):
npx package-diff <package-name>@<old-version> <package-name>@<new-version>
# Or manually: download both tarballs and diff

DefenceExact version pinning + lock files. A lock file records the exact version last reviewed; it must be updated intentionally (not automatically on npm install).


4. Protestware and Intentional Sabotage

A maintainer of a widely-used package intentionally introduces malicious or destructive code, often for political or ideological reasons.

IncidentPackageWhat happened
node-ipc (2022)node-ipcMaintainer added code to wipe files on machines with Russian/Belarusian IP addresses
colors.js + faker.js (2022)colors, fakerMaintainer published infinite-loop versions to protest not being paid; broke thousands of apps
event-stream (2018)event-streamMaintainer transferred to unknown party; new version added wallet theft targeting Copay app

Why it's hard to defend againstThe maintainer has legitimate access — no account compromise, no phishing. The only defence is code review of version updates.

bash
# Review the diff before updating a dependency
# Using npm pack to inspect the tarball
npm pack <package-name>@<version>
tar -xzf <package-name>-<version>.tgz
find package/ -name "*.js" | xargs grep -E 'exec|eval|child_process|fs\.write|net\.|axios|fetch'

# Look specifically at install scripts
cat package/package.json | jq '.scripts'
# "postinstall", "preinstall", "install" scripts run automatically
# Any network calls, file writes outside the package, env var reads = red flag

5. Subdependency / Transitive Attack

Attacking a package deep in the dependency tree that many projects depend on transitively.

Your app
  └── webpack
        └── terser
              └── source-map-support
                    └── [compromised-deep-transitive-dep]
                            └── malicious code runs at build time

The compromised package may have millions of dependents but individually low download numbers per project, making it hard to track.

bash
# Map your full dependency tree (npm)
npm ls --all            # full tree
npm ls <package-name>   # find where a specific package comes from

# Check if a package is in your transitive tree
npm why <package-name>   # shows dependency path

# Cargo
cargo tree              # full dependency tree
cargo tree -p <crate>   # subtree for specific crate

# Python
pipdeptree              # install: pip install pipdeptree
pipdeptree -p <package>

npm / pnpm / yarn — JavaScript and TypeScript

Lock Files

npm   → package-lock.json   (lockfileVersion 3 in npm 7+)
yarn  → yarn.lock
pnpm  → pnpm-lock.yaml

Always commit lock files. A lock file records exact resolved versions and integrity hashes for every package in the tree. Without it, npm install may resolve different versions on different machines.

bash
# npm — integrity hashes in package-lock.json
# Every package has an "integrity" field: sha512-<base64>
# npm verifies this hash against what it downloads — tampered packages are rejected
cat package-lock.json | jq '.packages["node_modules/lodash"].integrity'
# "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gAzMVEqhO3Wj88qqFZaUzvqGENqHbJ6t4nGbYe/mMqxuuAdTntoMBiTOBfw=="

Version Range Dangers

json
// package.json — the ^ and ~ wildcards silently accept new versions
{
  "dependencies": {
    "lodash": "^4.17.21",   // accepts 4.x.x — any new minor or patch
    "express": "~4.18.0",   // accepts 4.18.x — any new patch
    "axios": "1.6.0",       // exact version — safest
    "react": "*"            // any version — never do this in production
  }
}
bash
# Lock your dependencies to exact versions
# Option 1: use exact specifiers in package.json
npm install --save-exact lodash   # adds "lodash": "4.17.21" (no ^)

# Option 2: .npmrc to always save exact
echo "save-exact=true" >> .npmrc

# Option 3: pnpm — by default uses exact versions in pnpm-lock.yaml
# pnpm-lock.yaml always stores resolved + integrity — more deterministic than npm

Install Script Auditing

npm, yarn, and pnpm all run preinstall, install, and postinstall scripts automatically. This is the most common malware delivery mechanism.

bash
# Review install scripts before running npm install
# Use --ignore-scripts to suppress all install hooks
npm install --ignore-scripts

# Audit install scripts in all dependencies
npx can-i-ignore-scripts   # lists which packages use install scripts

# pnpm — explicitly approved install scripts only
# In package.json:
{
  "pnpm": {
    "onlyBuiltDependencies": ["esbuild", "sharp"]
    // only these packages are allowed to run install scripts
  }
}

# Or use pnpm's allowedDeprecatedVersions / overrides to control installs

npm Audit and Dependency Scanning

bash
# Built-in vulnerability scanning
npm audit
npm audit --audit-level=high   # fail only on high/critical
npm audit fix                  # auto-upgrade to patched versions

# pnpm
pnpm audit
pnpm audit --fix

# yarn
yarn audit

# Check for outdated packages
npm outdated

# View full info on a package (check author, homepage, repo)
npm view <package-name>

# Look at download counts to assess legitimacy
# A package with 5 downloads/week claiming to do something important is suspicious
npm view <package-name> downloads-last-week

pnpm-Specific Security Features

pnpm has stronger supply-chain defaults than npm:

yaml
# .npmrc or package.json pnpm config

# Strict hoisting — no phantom dependencies
# (packages can only use what's listed in their own package.json)
node-linker=isolated   # pnpm's default; npm has hoisted mode which is more permissive

# Frozen lockfile in CI — fail if pnpm-lock.yaml would change
# package.json scripts:
"ci": "pnpm install --frozen-lockfile"

# Disable install scripts globally (then approve individually)
# .npmrc:
ignore-scripts=true

Cargo — Rust

Cargo (Rust's package manager) has several security advantages by design, and a growing set of auditing tools.

Cargo.lock and Exact Pinning

Cargo always generates a Cargo.lock with exact versions and checksums for every dependency. Unlike npm, Cargo uses this consistently:

toml
# Cargo.lock (generated — do NOT manually edit, but DO commit it for binaries)
[[package]]
name = "serde"
version = "1.0.195"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63261df402c1e1b9607b6cf450f462bee1f4e34df7fe6f2d4a4e8f6d50b6deef"
dependencies = [
  "serde_derive",
]

The checksum field is a SHA-256 of the crate tarball. If crates.io serves a different file, Cargo refuses to build. This is significantly stronger than npm's optional integrity checking.

toml
# Cargo.toml — version specifiers
[dependencies]
serde = "1.0"              # accepts >=1.0.0 <2.0.0 (SemVer compatible)
tokio = "=1.35.1"          # exact version (pinned)
reqwest = { version = "0.11", features = ["json"] }
bash
# Commit Cargo.lock for binaries and applications
# Library crates: Cargo.lock is not published to crates.io anyway
# Binary projects: always commit Cargo.lock

# Verify checksums manually
cargo fetch --locked   # fails if Cargo.lock would change

cargo audit — RustSec Advisory Database

bash
# Install
cargo install cargo-audit

# Scan all dependencies for known CVEs
cargo audit

# Example output:
# Crate:     openssl
# Version:   0.10.55
# Warning:   unmaintained
# Title:     Unsafe use of deprecated functions
# ID:        RUSTSEC-2023-0072

# Fail CI on high severity
cargo audit --deny warnings   # treat all advisories as errors
cargo audit --deny unsound    # only fail on unsound/memory-safety issues

cargo deny — Comprehensive Policy Enforcement

cargo deny goes beyond CVE scanning — it enforces license policies, bans specific crates, checks for duplicate versions, and validates sources.

bash
cargo install cargo-deny

# Generate initial config
cargo deny init
toml
# deny.toml
[advisories]
db-path = "~/.cargo/advisory-db"
db-urls = ["https://github.com/rustsec/advisory-db"]
vulnerability = "deny"    # deny = fail build on any vulnerability
unmaintained = "warn"
yanked = "deny"

[licenses]
unlicensed = "deny"
allow = ["MIT", "Apache-2.0", "BSD-3-Clause"]
deny = ["GPL-3.0"]        # no GPL in this commercial product

[bans]
multiple-versions = "warn"    # alert on diamond dependency conflicts
deny = [
  { name = "openssl", reason = "use rustls instead" },
]

[sources]
unknown-registry = "deny"     # only allow crates.io and listed alternatives
unknown-git = "deny"
allow-git = []                # explicitly allow specific git sources
bash
cargo deny check               # run all checks
cargo deny check advisories    # only CVE check
cargo deny check licenses      # only license check

pip / Poetry / uv — Python

Python's ecosystem has historically had weaker supply-chain controls than Rust or Go, but tooling has improved significantly.

Hash Pinning in requirements.txt

The safest requirements.txt approach pins exact version and the SHA-256 hash of the wheel/sdist:

# Generate hash-pinned requirements
pip-compile --generate-hashes requirements.in > requirements.txt

# requirements.txt output (hash-pinned):
requests==2.31.0 \
    --hash=sha256:58cd2187423d55f2b2e0168b27e279faa7e1a3fea5b4fdc36e1e4c5a4c62d2a2 \
    --hash=sha256:1533d2b771b8f34a81a9e73d93be889bc8d012e6af79c1d4f18a3c3f4c5b1c4e
# Two hashes = sha256 of the wheel for different platforms

# Install must verify hashes (automatic when hashes are present in requirements.txt)
pip install --require-hashes -r requirements.txt
# If hash doesn't match → installation fails with error

Poetry

Poetry uses poetry.lock which stores SHA-256 content hashes for every package:

toml
# poetry.lock (generated — commit this)
[[package]]
name = "requests"
version = "2.31.0"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.7"
files = [
    {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187..."},
    {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d7..."},
]
bash
# Install locked — fail if lock would change
poetry install --no-update

# In CI — strict mode
poetry install --frozen

# Check for vulnerabilities
poetry run pip-audit   # pip-audit works with poetry virtualenvs

uv — Fast Python Package Manager (Astral)

uv is a new Rust-based Python package manager that is significantly faster than pip and has better security defaults:

bash
# Install uv
pip install uv   # or: curl -LsSf https://astral.sh/uv/install.sh | sh

# Create a project
uv init myproject
uv add requests      # adds to pyproject.toml, updates uv.lock

# uv.lock stores exact versions + hashes — similar to Cargo.lock
# Always commit uv.lock

# Install from lock (fail if lock would change)
uv sync --frozen

# Audit for vulnerabilities
uv run pip-audit

pip-audit — CVE Scanning

bash
pip install pip-audit

# Scan current environment
pip-audit

# Scan a requirements file
pip-audit -r requirements.txt

# Scan with SBOM output (CycloneDX)
pip-audit -r requirements.txt --format cyclonedx-json > sbom.cdx.json

# Fix known vulnerabilities (upgrades to patched versions)
pip-audit --fix

PyPI Threat-Specific Defences

bash
# Check package metadata before installing
pip index versions <package-name>    # see all versions
pip show <package-name>              # check author, homepage (after install)

# Use the API to check publication date
curl https://pypi.org/pypi/<package>/json | python3 -c "
import json, sys
data = json.load(sys.stdin)
for ver, files in sorted(data['releases'].items())[-5:]:
    if files:
        print(ver, files[0]['upload_time'])
"

# Verify checksums for downloaded packages
pip download <package>==<version>
sha256sum <package>-<version>.whl
# Compare against PyPI's listed hash

# Private PyPI mirror — only serve vetted packages
# In pip.conf:
[global]
index-url = https://pypi.company.com/simple/
trusted-host = pypi.company.com

Go Modules

Go's module system has some of the strongest supply-chain security properties of any ecosystem by default.

go.sum — Cryptographic Verification

Every module has two entries in go.sum:

# go.sum entries
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt38=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=

# h1: is a Hash/1 tree hash of the module zip contents
# go.mod hash verifies the module's go.mod file specifically

go.sum is a tamper-evident log — if the upstream module is modified and serves different content, go get / go build will fail because the new hash won't match go.sum.

bash
# go.sum is automatically updated by go get / go mod tidy
# Always commit go.sum — it is the security anchor of your build

# Build with exact locked dependencies
go build -mod=readonly ./...   # fails if go.sum would need updating
# In CI, use:
GOFLAGS=-mod=readonly go build ./...

# Verify module checksums against the checksum database (sum.golang.org)
go mod verify

# The checksum database (sum.golang.org) is a global transparency log
# Every module version uploaded is logged, and go verifies against this log
# Tampering with a published module version is detectable globally

govulncheck — Go Vulnerability Scanner

bash
# Install
go install golang.org/x/vuln/cmd/govulncheck@latest

# Scan current module
govulncheck ./...

# Scan a specific binary
govulncheck -mode=binary ./myapp

# Output structured JSON for CI
govulncheck -json ./...

# Example output:
# Vulnerability #1: GO-2023-1558
# More info: https://pkg.go.dev/vuln/GO-2023-1558
# Module: golang.org/x/net
# Found in: golang.org/x/net@v0.0.0-20230301155059-4b61b3831e6e
# Fixed in: golang.org/x/net@v0.8.0

GONOSUMCHECK and GONOSUMDB

bash
# GONOSUMDB: skip checksum database for private modules
# (needed for private repos that aren't in the public checksum database)
GONOSUMDB=*.company.com
GONOSUMDB=github.com/company/*

# GOFLAGS for CI security
GONOSUMCHECK=""       # always check sum (default) — don't override in production
GOFLAGS="-mod=readonly"
GOTELEMETRY=off       # disable telemetry

Maven / Gradle — Java and Kotlin

Maven — Dependency Management

xml
<!-- pom.xml — specify exact versions, not ranges -->
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>3.2.1</version>  <!-- exact version — no [1.0,2.0) ranges -->
  </dependency>
</dependencies>

<!-- Enforce consistent versions across multi-module projects -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.16.1</version>
    </dependency>
  </dependencies>
</dependencyManagement>
bash
# Check for dependency vulnerabilities
mvn dependency:check   # requires OWASP Dependency-Check plugin in pom.xml

# View effective dependency tree (resolve conflicts)
mvn dependency:tree

# Check for available updates
mvn versions:display-dependency-updates

# Build reproducibly (Maven 3.9+)
mvn -Dmaven.artifact.threads=1 -Dartifact.buildinfo.attach=true verify

Gradle

kotlin
// build.gradle.kts — exact versions with verification metadata
dependencies {
    implementation("com.google.guava:guava:33.0.0-jre")
    // Avoid dynamic versions:
    // implementation("com.google.guava:guava:+")      // bad
    // implementation("com.google.guava:guava:33.+")   // bad
}

// Enable dependency verification (generates checksums)
// gradle/verification-metadata.xml
bash
# Generate verification metadata (checksums for all deps)
./gradlew --write-verification-metadata sha256

# gradle/verification-metadata.xml is generated:
# <component group="com.google.guava" name="guava" version="33.0.0-jre">
#   <artifact name="guava-33.0.0-jre.jar">
#     <sha256 value="d3b189cd..." origin="Generated by Gradle"/>
#   </artifact>
# </component>

# Subsequent builds verify against these checksums
./gradlew build   # fails if checksums don't match

# OWASP Dependency-Check for Gradle
./gradlew dependencyCheckAnalyze

Ruby Gems

bash
# Gemfile.lock — always commit for applications
# The lock file pins exact versions and checksums

# Audit for vulnerabilities
gem install bundler-audit
bundle audit            # check against Ruby Advisory Database
bundle audit update     # update the advisory DB first

# Check for outdated gems
bundle outdated

# Generate a hash-verified Gemfile
bundle config set --global frozen true   # prevents Gemfile.lock from changing

# In CI: install without updating the lock
bundle install --deployment   # treats Gemfile.lock as authoritative

Universal Best Practices

1. Commit All Lock Files

npm      → package-lock.json or pnpm-lock.yaml  ✓ commit
cargo    → Cargo.lock (for binaries)             ✓ commit
python   → poetry.lock or uv.lock               ✓ commit
go       → go.sum                               ✓ commit
maven    → pom.xml versions are the lock        ✓ commit
gradle   → gradle/verification-metadata.xml     ✓ commit
ruby     → Gemfile.lock                         ✓ commit

Lock files are security artifacts — losing them means losing your verification chain.

2. Exact Version Pinning Over Ranges

SpecifierMeaningRisk
^1.0.0 (npm)>=1.0.0 <2.0.0Any minor/patch accepted
~1.0.0 (npm)>=1.0.0 <1.1.0Any patch accepted
>=1.0 (pip)Anything from 1.0All future versions
1.0.0Exactly 1.0.0Safe, explicit
1.0.0 + hashExact contentSafest — bytes verified
bash
# npm — set save-exact globally
npm config set save-exact true

# pip — pin with hashes
pip-compile --generate-hashes requirements.in

# Cargo — use = prefix for exact
serde = "=1.0.195"

3. The 7-Day Rule in CI

yaml
# GitHub Actions — custom check for new packages
- name: Check dependency ages
  run: |
    # For npm: check all direct dependencies
    node -e "
    const pkg = require('./package.json');
    const { execSync } = require('child_process');
    const deps = {...pkg.dependencies, ...pkg.devDependencies};
    const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
    const now = Date.now();

    for (const [name, version] of Object.entries(deps)) {
      const exactVersion = version.replace(/[\^~>=<]/, '');
      try {
        const info = JSON.parse(execSync(\`npm view \${name}@\${exactVersion} time --json\`, {encoding:'utf8'}));
        const published = new Date(Object.values(info).pop()).getTime();
        if (now - published < SEVEN_DAYS) {
          console.error(\`FAIL: \${name}@\${exactVersion} was published less than 7 days ago\`);
          process.exit(1);
        }
      } catch(e) { console.log(\`Could not check \${name}: \${e.message}\`); }
    }
    console.log('All dependencies passed the 7-day rule');
    "

4. Minimal Dependency Principle

Every dependency is an attack surface. Before adding a package:

Questions to ask before adding a dependency:
  1. Can I implement this in < 50 lines without the dependency?
  2. How many of this package's own dependencies would I also be pulling in?
     npm ls <package> --all | wc -l
  3. How many maintainers does this have? (single-maintainer = single point of failure)
     npm view <package> maintainers
  4. When was it last updated? (abandoned packages don't get security patches)
  5. Does it have install scripts?
     npm view <package> scripts
  6. Does this package actually need to make network connections? (if no, flag if it does)

5. Build Script Auditing

bash
# List all packages that have install scripts (npm)
cat node_modules/.package-lock.json | python3 -c "
import json,sys
data = json.load(sys.stdin)
for pkg, info in data.get('packages', {}).items():
    if any(s in info.get('scripts', {}) for s in ['preinstall','install','postinstall']):
        print(pkg, info.get('scripts', {}))
"

# pnpm — approve install scripts explicitly
# pnpm-workspace.yaml or package.json:
{
  "pnpm": {
    "onlyBuiltDependencies": [
      "esbuild",         # these are explicitly reviewed and trusted
      "sharp",
      "@swc/core"
    ]
  }
}

# If an unexpected package is running an install script: STOP and investigate

6. Private Registry Mirroring

See dedicated section below.

7. Reproducible Builds

A reproducible build produces byte-for-byte identical output given the same source and toolchain, regardless of when or where it runs.

bash
# Go — reproducible by default (module graph is deterministic with go.sum)
go build -trimpath ./...    # -trimpath removes local path info from binary

# Rust — cargo is mostly reproducible; use same toolchain
# Pin toolchain version in rust-toolchain.toml:
[toolchain]
channel = "1.75.0"   # exact version, not "stable"

# Docker — use digest-pinned base images
# Instead of:
FROM node:20-alpine
# Use:
FROM node:20-alpine@sha256:bf6c61db4...  # exact image digest

# npm -- ensure CI uses the lock
npm ci   # always use 'npm ci' in CI, not 'npm install'
# npm ci: deletes node_modules, installs exactly from package-lock.json
#         fails if package-lock.json is missing or out of sync with package.json

# pnpm
pnpm install --frozen-lockfile

# cargo
cargo build --locked   # fails if Cargo.lock would need updating

Private Registry Mirroring

A private registry proxy sits between your build system and the public registry. It:

  • Caches and serves packages from your network (faster, no public internet dependency)
  • Allows scanning before packages are served
  • Blocks packages not on an allowlist
  • Prevents dependency confusion by controlling namespace resolution
Build system → Private Nexus/Artifactory/Verdaccio → (approved packages only) → Public registry

Tools

ToolSupportsDeployment
Artifactory (JFrog)npm, PyPI, Maven, Cargo, Go, DockerEnterprise SaaS/self-hosted
Nexus Repositorynpm, PyPI, Maven, GoEnterprise self-hosted
Verdaccionpm onlySimple self-hosted (Node.js)
Cloudsmithnpm, PyPI, Maven, Cargo, DockerCloud SaaS
GitHub Packagesnpm, Maven, Gradle, NuGet, DockerGitHub-hosted
AWS CodeArtifactnpm, PyPI, MavenAWS-native

Configuration Examples

bash
# npm / pnpm — point to private registry
# .npmrc in project root:
registry=https://nexus.company.com/repository/npm-proxy/
//nexus.company.com/repository/npm-proxy/:_authToken=${NPM_TOKEN}

# Scoped packages route to private, everything else to public proxy
@company:registry=https://nexus.company.com/repository/npm-private/

# Python — pip.conf or .pip/pip.conf
[global]
index-url = https://nexus.company.com/repository/pypi-proxy/simple/
trusted-host = nexus.company.com

# Cargo — .cargo/config.toml
[source.crates-io]
replace-with = "company-mirror"

[source.company-mirror]
registry = "https://nexus.company.com/repository/cargo/"

# Maven — settings.xml
<mirror>
  <id>company-nexus</id>
  <mirrorOf>*</mirrorOf>
  <url>https://nexus.company.com/repository/maven-proxy/</url>
</mirror>

SCA and Behavioural Analysis Tools

Traditional SCA (CVE-based)

ToolLanguagesCI integration
Snyknpm, PyPI, Maven, Cargo, Go, RubyGitHub, GitLab, CLI
Dependabotnpm, PyPI, Maven, Cargo, Go, RubyGitHub-native
OWASP Dependency-CheckJava, .NET, Ruby, NodeMaven, Gradle, CLI
Trivynpm, PyPI, Maven, Cargo, GoCLI, Docker, Kubernetes
Grypenpm, PyPI, Maven, Cargo, GoCLI
pip-auditPythonCLI
cargo auditRustCLI
govulncheckGoCLI

Behavioural Analysis (Beyond CVEs)

Socket.dev — analyses npm and PyPI packages for suspicious behaviour, not just known CVEs. It detects:

  • Install scripts that make network connections
  • Code that reads environment variables (potential credential theft)
  • eval() or dynamic code execution
  • Obfuscated JavaScript
  • Packages that were updated very recently (flagged for review)
  • Typosquatting patterns
bash
# Install Socket CLI
npm install -g socket

# Audit your package.json before installing
socket npm install <package-name>   # replaces 'npm install', shows risk report

# In CI — fail on high-risk packages
socket ci npm install --strict

OSS-Fuzz — Google's continuous fuzzing for open-source projects. Check if a dependency is covered (fuzz-tested projects are less likely to have memory corruption bugs).

Renovate and Dependabot — Automated Updates

Automated update bots keep your lock files current with security patches:

yaml
# renovate.json — Renovate configuration
{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:recommended"],
  "packageRules": [
    {
      "matchUpdateTypes": ["minor", "patch"],
      "automerge": true,        // auto-merge safe updates
      "minimumReleaseAge": "7 days"  // enforce the 7-day rule!
    },
    {
      "matchUpdateTypes": ["major"],
      "automerge": false,       // require review for major updates
      "minimumReleaseAge": "14 days"
    }
  ],
  "vulnerabilityAlerts": {
    "enabled": true,
    "minimumReleaseAge": "0 days"  // security patches bypass the wait
  }
}
yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
    ignore:
      - dependency-name: "*"
        update-types: ["version-update:semver-major"]  # don't auto-major

Real-World Incidents Reference

YearPackageEcosystemAttack typeImpact
2018event-streamnpmOwnership transfer → malicious codeTargeted Copay Bitcoin wallet; millions of downloads
2021ua-parser-jsnpmAccount takeoverCryptominer + credential stealer in 8M+ downloads/week package
2021rc, coanpmAccount takeover (same attacker)Postinstall malware; broke many CI pipelines
2021Multiplenpm/PyPIDependency confusionAlex Birsan PoC — 35 major companies affected
2021PyTorch-nightlyPyPIDependency confusiontorchtriton internal name squatted on PyPI
2021xz-utilsLinux distrosLong-term insider (Jia Tan)SSH backdoor; 2-year build-up; caught by Andres Freund
2022colors.js, faker.jsnpmProtestware (maintainer)Infinite loop; broke thousands of dependent apps
2022node-ipcnpmProtestware (maintainer)File wiping on RU/BY IPs; affected vue-cli
2022ctx (PyPI)PyPITyposquattingStole env vars (AWS keys)
2022SolarWinds OrionEnterpriseBuild pipeline injectionSUNBURST backdoor; ~18k organisations
2024xzLinux (Debian/Fedora sid)Insider + social engineeringSSH backdoor via systemd-sshd; caught pre-wide-release
2025tj-actions/changed-filesGitHub ActionsWorkflow token theftCI secrets exposed across thousands of repos (CVE-2025-30066)

Key lessons from these incidents

Account security matters

MFA on all registry accounts prevents most account-takeover attacks

Ownership transfers are high risk

when event-stream's maintainer transferred the project, no audit was done

Insider/long-term attacks bypass all scanning

XZ Utils passed every automated check; only caught by performance regression

Build pipelines are targets, not just code

SolarWinds and tj-actions show that compromising the build is as good as compromising the code


Interview Questions and Answers

Q
What is dependency confusion and how does it differ from typosquatting?
Model answer

Dependency confusionattacker publishes a public package with the same name as an internal private package at a higher version number — the package manager picks the "better" public version. Exploits namespace conflicts in package resolution. Typosquatting: attacker publishes reqeusts (deliberate typo of requests) hoping developers mistype. Dependency confusion exploits the package manager's own resolution logic; typosquatting exploits human error.


Q
Why does npm install --ignore-scripts improve security? What breaks if you use it?
Model answer

Malicious packages commonly run code in postinstall/preinstall lifecycle hooks during npm install — this is how most npm supply chain attacks exfiltrate tokens or install persistence. --ignore-scripts prevents all lifecycle scripts from running. What breaks: packages that need to compile native code (esbuild, node-sass, Puppeteer) won't build. Mitigation: use pnpm's onlyBuiltDependencies to allow only an explicitly approved list of packages to run install scripts.


Q
What is the difference between npm install and npm ci? Which do you use in CI and why?
Model answer

npm install may update node_modules and can modify package-lock.json to resolve new versions. npm ci deletes node_modules entirely and installs exactly what's in the existing package-lock.json — deterministic, fast, and fails if the lock file is out of sync with package.json. Always use npm ci in CI/CD to guarantee exact dependency versions and prevent silent lock file drift.


Q
Explain what a lock file does for supply chain security. What attack does it prevent? What doesn't it prevent?
Model answer

A lock file (package-lock.json, Cargo.lock) pins exact versions and integrity hashes of all direct and transitive dependencies. Prevents: ^1.0.0 silently pulling a later malicious 1.0.5 release. Doesn't prevent: an attacker who compromises the package maintainer account and republishes the exact same pinned version with malicious code — some registries allow overwriting a version's content without changing the version number (npm historically allowed npm unpublish + republish with same version).


Q
What is the 7-day rule for dependencies? What class of attack does it catch, and what doesn't it catch?
Model answer

The 7-day rule delays dependency updates for at least 7 days after a new version is published. Most community-detected supply chain attacks (typosquatting, dependency confusion, early malicious packages) are reported within the first week — the community notices and removes the package before you've installed it. Doesn't catch: long-lived sleeper packages that appear legitimate for months before activating a payload (e.g., XZ Utils — Jia Tan contributed for 2+ years before inserting the backdoor).


Q
How does go.sum provide stronger security guarantees than package-lock.json?
Model answer

go.sum records the expected hash of every module version AND the hash of its go.mod file, committed to the repository. The Go module proxy (sum.google.com) provides a public transparency log — every module hash is publicly auditable and tamper-evident; any future change to a published module is detectable. package-lock.json pins versions and hashes but there's no independent public log; npm allows packages to be yanked and republished with different content.


Q
What is cargo deny and what can it enforce beyond CVE scanning?
Model answer

cargo deny is a policy-as-code tool for Rust/Cargo. Beyond CVE scanning (RustSec advisory DB), it enforces: licenses (allow-list MIT/Apache-2.0, deny GPL-3.0), dependency bans (block specific crate versions or deprecated crates), duplicate detection (alert if two versions of the same crate are pulled), and sources (only allow crates.io + approved git sources — blocks private registry confusion). Configured via deny.toml.


Q
How does pip install --require-hashes work? What does it verify?
Model answer

--require-hashes requires every package in the requirements file to have an explicit --hash=sha256:... annotation. The installer downloads the wheel/sdist and computes its hash — if it doesn't match, installation fails with an error. This guarantees integrity: even if a registry is compromised or a typosquat package is published, the hash won't match. Generate the hashes with pip-compile --generate-hashes (pip-tools).


Q
Walk me through how the event-stream attack worked and what controls would have caught it.
Model answer

event-stream v3.3.6 added flatmap-stream as a new dependency. flatmap-stream contained an encrypted payload that decrypted only when it found Copay's Bitcoin wallet configuration in the environment, then exfiltrated private keys. What would have caught it: Socket.dev behavioral analysis (new dependency making network calls in install script); version pinning to 3.3.5 with hash verification; npm ci with a locked hash (would require explicit review of the new dependency). Standard CVE scanners wouldn't catch it — no CVE existed yet.


Q
What is the XZ Utils backdoor? Why did it go undetected by automated tooling?
Model answer

"Jia Tan" (likely state-sponsored) contributed legitimately to xz/liblzma for 2+ years and gained maintainer trust. In 2024 they inserted a backdoor in release tarballs (not in the git source) that patched sshd via systemd on affected systems. Automated scanners missed it because: no CVE existed, the malicious code was in build artifacts not source, and the manipulation was extremely subtle (test file modification). Andres Freund discovered it accidentally by noticing high CPU usage in sshd.


Q
How would you architect a private npm registry to prevent dependency confusion?
Model answer

(1) Publish all internal packages with a scoped name (@company/package-name). (2) Configure .npmrc: @company:registry=https://private.registry.company. (3) In the private registry, configure a scope isolation policy that blocks any request for @company/ packages from being proxied to the public npm registry — so there's no fallback path to the public registry for your scope. (4) Disable upstream proxy for internal package names entirely.


Q
What does Socket.dev do differently from Snyk or Dependabot?
Model answer

Snyk and Dependabot are CVE-database-driven — they alert on packages with known published vulnerabilities. They're blind to a brand-new malicious package with no CVE. Socket.dev performs behavioral analysis of the package code itself: network calls in install scripts, shell execution, newly created maintainer accounts (<7 days old), newly added permissions, obfuscated code. It detects novel malicious packages before they have CVEs.


Q
What is the minimumReleaseAge Renovate setting and why is it important?
Model answer

minimumReleaseAge: "7 days" tells Renovate not to open a dependency-update PR until the new version is at least 7 days old. This implements the 7-day community detection window in automation — even if a malicious version is published, Renovate won't auto-update you to it before the community has had a chance to discover and remove it. Configure in renovate.json.


Q
How do you detect whether a newly added npm package is running a network call during install?
Model answer

Option 1: npm install --ignore-scripts first, then inspect package.json for preinstall/postinstall fields before deciding to allow them. Option 2: run npm install inside a network-isolated sandbox and check strace -e trace=network npm install or tcpdump — any outbound connection during install is suspicious. Option 3: npx socket npm install <package> (Socket.dev CLI) intercepts and analyzes the package before installation completes.


Q
What are the risks of using * or >=1.0 as version specifiers in production?
Model answer

* or >=1.0 means any future version satisfies the constraint — every npm install may pull a different, potentially malicious version. An attacker who compromises a package maintainer account can publish 1.2.0 with a backdoor and all consumers of >=1.0 automatically receive it on next install. This also breaks reproducible builds. Always pin to exact versions or narrow semver ranges (~1.2.3) combined with a lock file and hash verification.