Why Static Sites Have Supply Chains

A static site generator produces HTML, CSS, and JavaScript from source templates and content. The output is simple. The build process is not.

A typical Eleventy or Hugo project depends on:

  • The generator itself — an npm package with its own dependency tree
  • Plugins and transforms — Markdown processors, image optimisers, syntax highlighters, each with their own dependencies
  • Build tools — bundlers, minifiers, PostCSS, Sass compilers
  • CI/CD runners — GitHub Actions, GitLab CI, Cloudflare Pages build environment
  • Container images — if builds run in Docker, the base image and every layer it pulls
  • Node.js runtime — the specific version used to execute the build

A fresh Eleventy project with a handful of plugins easily pulls in 200–400 npm packages. Each package is a potential point of compromise. The event-stream incident (2018), ua-parser-js (2021), colors and faker (2022), and xz-utils (2024) demonstrate that supply-chain attacks against build dependencies are not hypothetical — they are routine.

The fact that your output is static HTML does not protect you if your build process is compromised. A malicious dependency can inject scripts into your output, exfiltrate environment variables (including deployment credentials), or modify content in ways that are difficult to detect.

SBOM Minimum Elements: What CISA and NTIA Require

A Software Bill of Materials (SBOM) is an inventory of every component in your software. The CISA/NTIA "Minimum Elements for a Software Bill of Materials" (published 2021, updated guidance through 2025) defines the baseline fields that an SBOM must include:

Required Data Fields

For each component in the SBOM:

Field Description Example
Supplier Name Entity that created the component @11ty
Component Name Name of the component @11ty/eleventy
Version Version identifier 3.0.0
Unique Identifier PURL, CPE, or SWID pkg:npm/@11ty/[email protected]
Dependency Relationship How this component relates to others eleventy DEPENDS_ON markdown-it
Author of SBOM Data Who generated the SBOM wplus-ci-pipeline
Timestamp When the SBOM was generated 2026-04-19T04:49:00Z

SBOM Formats

Two standard formats dominate:

  • SPDX (ISO/IEC 5962:2021) — maintained by the Linux Foundation. Widely supported, JSON and tag-value formats.
  • CycloneDX — maintained by OWASP. Designed specifically for security use cases. JSON and XML formats.

Both are acceptable. For npm-based projects, CycloneDX has slightly better tooling integration.

Generating SBOMs for npm Projects

# Using CycloneDX CLI tool
npm install -g @cyclonedx/cyclonedx-npm
cyclonedx-npm --output-file sbom.json --output-format json

# Using SPDX SBOM generator
npm install -g @spdx/sbom-generator
spdx-sbom-generator -o sbom.spdx.json

In CI (GitHub Actions example):

- name: Generate SBOM
  run: |
    npx @cyclonedx/cyclonedx-npm --output-file sbom.json --output-format json
    
- name: Upload SBOM as artifact
  uses: actions/upload-artifact@v4
  with:
    name: sbom
    path: sbom.json
  • [ ] Generate an SBOM on every production build
  • [ ] Store SBOMs as build artefacts alongside deployed output
  • [ ] Include the SBOM in your deployment record (even if not publicly published)

SSDF Practices Adapted for Static Sites

The NIST Secure Software Development Framework (SSDF, SP 800-218) defines practices for secure development. Not all practices apply directly to static site projects, but the core principles do. Here is how to adapt the relevant practices:

PO.1: Define Security Requirements

For a static site, security requirements include:

  • [ ] No inline JavaScript in output (enforceable via CSP)
  • [ ] All third-party resources loaded with Subresource Integrity (SRI) hashes
  • [ ] No secrets in the repository or build output
  • [ ] Dependency versions pinned in lockfiles
  • [ ] Build output is reproducible from the same inputs

Document these in a SECURITY.md or equivalent file in your repository.

PS.1: Protect All Forms of Code

  • [ ] Require signed commits or enforce branch protection rules
  • [ ] Protect the main branch — require pull-request reviews before merging
  • [ ] Use repository secrets for deployment credentials — never commit them
  • [ ] Enable secret scanning (GitHub, GitLab) to catch accidental exposure
  • [ ] Restrict who can modify CI/CD configuration.github/workflows/ files should require review

PW.4: Review and/or Analyse Code

For static sites, the most impactful code review focuses on:

  • Changes to package.json or package-lock.json — any new dependency or version change
  • Changes to build scripts and CI configuration
  • Changes to templates that include external resources
  • Changes to redirect rules and header configurations

Automate what you can. Use Dependabot or Renovate to flag dependency updates, but require human review before merging.

PW.6: Verify Third-Party Components

This is where supply-chain security becomes concrete:

# Run npm audit on every build
npm audit --audit-level=high

# If using a lockfile (and you should be), verify its integrity
npm ci  # This verifies the lockfile matches package.json and checks integrity hashes

Integrate into CI:

- name: Install dependencies with integrity verification
  run: npm ci

- name: Audit dependencies
  run: npm audit --audit-level=high
  continue-on-error: false  # Fail the build on high-severity vulnerabilities

PW.7: Build Securely

  • [ ] Use npm ci, not npm install in CI — npm ci installs from the lockfile exactly, verifying integrity hashes. npm install may modify the lockfile.
  • [ ] Pin the Node.js version — specify in .nvmrc, .node-version, or engines in package.json. Use the same version in development and CI.
  • [ ] Pin CI runner versions — use specific action versions (actions/[email protected], not actions/checkout@v4) to prevent supply-chain attacks through action updates.
  • [ ] Minimise CI permissions — use permissions: read-all at the workflow level and grant write access only where needed.
  • [ ] Isolate the build environment — CI runners should not have access to production infrastructure beyond the deployment target.

Lockfile Integrity: The Foundation

The package-lock.json (npm) or yarn.lock (Yarn) file is the most critical supply-chain artefact in your project. It contains:

  • Exact versions of every dependency (direct and transitive)
  • Integrity hashes (SHA-512) for every package tarball
  • Resolved URLs for every package

When npm ci runs, it verifies that the downloaded package tarballs match the integrity hashes in the lockfile. If a package has been tampered with on the registry, the hash will not match, and the install will fail.

Lockfile Hygiene

  • [ ] Commit the lockfile — it must be in version control. Never .gitignore it.
  • [ ] Review lockfile changes in PRs — use npm diff or GitHub's lockfile diff to understand what changed.
  • [ ] Regenerate periodically — delete node_modules and package-lock.json, run npm install, verify the result, and commit. This catches cases where the lockfile has drifted from package.json.
  • [ ] Use npm ci in CI, always — never npm install.

Detecting Lockfile Manipulation

The lockfile-lint package can enforce policies on your lockfile:

npx lockfile-lint --path package-lock.json --type npm \
  --allowed-hosts npm \
  --allowed-schemes "https:" \
  --empty-hostname false

This verifies that:

  • All packages resolve to the official npm registry
  • All URLs use HTTPS
  • No packages resolve to unexpected hosts

Add to CI:

- name: Lint lockfile
  run: npx lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --allowed-schemes "https:" --empty-hostname false

CI Hardening

The CI environment is a high-value target. It has access to your source code, your deployment credentials, and the ability to publish content to your production site.

GitHub Actions Hardening Checklist

  • [ ] Set default permissions to read-only:
    permissions: read-all
    
  • [ ] Pin action versions to full SHA, not tags:
    uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
    
  • [ ] Enable Dependabot for GitHub Actions to receive updates when pinned actions have security fixes.
  • [ ] Use environment protection rules for production deployments — require approval, restrict to specific branches.
  • [ ] Limit secret exposure — use environment secrets scoped to specific deployment environments rather than repository-wide secrets.
  • [ ] Do not allow pull requests from forks to access secrets — this is the default, but verify it.
  • [ ] Enable audit logging and review periodically.

Reproducible Builds

A reproducible build produces identical output from identical inputs, regardless of when or where it runs. This is the gold standard for supply-chain integrity — if you can reproduce the build independently and get the same output, you can be confident the build was not tampered with.

For static sites, reproducibility challenges include:

  • Timestamps in output — many generators embed build timestamps. Configure them to use a fixed value (e.g., the Git commit timestamp).
  • Non-deterministic ordering — if file processing order varies between runs, output may differ. Pin sort orders in your configuration.
  • External data fetched at build time — API calls, remote data sources. Pin or cache these inputs.
  • Floating dependency versions — solved by lockfile discipline (see above).

Verify reproducibility:

# Build once
npm run build
cp -r _site/ _site_first/

# Clean and build again
rm -rf _site/
npm run build

# Compare
diff -r _site_first/ _site/

If the output differs, investigate and fix the source of non-determinism.

Practical CI Pipeline Example

Here is a complete GitHub Actions workflow incorporating SSDF-aligned practices:

name: Build and Deploy

on:
  push:
    branches: [main]

permissions: read-all

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    
    steps:
      - name: Checkout
        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1

      - name: Setup Node.js
        uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b  # v4.0.3
        with:
          node-version-file: '.nvmrc'

      - name: Install dependencies (integrity-verified)
        run: npm ci

      - name: Audit dependencies
        run: npm audit --audit-level=high

      - name: Lint lockfile
        run: npx lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --allowed-schemes "https:" --empty-hostname false

      - name: Build site
        run: npm run build

      - name: Generate SBOM
        run: npx @cyclonedx/cyclonedx-npm --output-file _site/sbom.json --output-format json

      - name: Upload build artefacts
        uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808  # v4.3.3
        with:
          name: site-output
          path: |
            _site/
            _site/sbom.json

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
    
    steps:
      - name: Download build artefacts
        uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e  # v4.1.7
        with:
          name: site-output
          path: _site/

      - name: Deploy
        run: |
          # Your deployment command here
          echo "Deploying to production"

Gotchas

npm audit has false positives. Not every reported vulnerability is exploitable in your context. A vulnerability in a development-only dependency's test suite does not affect your production output. Use npm audit --omit=dev to focus on production dependencies, but still review dev dependency issues periodically.

Pinning action SHAs is tedious but necessary. The tj-actions/changed-files incident (2025) demonstrated that a compromised GitHub Action can exfiltrate secrets from every repository using it. SHA pinning prevents the attacker from modifying the action after you reference it.

SBOM generation captures a point-in-time snapshot. If you deploy without generating a fresh SBOM, the artefact may not reflect the actual deployed dependencies. Generate the SBOM as part of every build, not as a separate periodic task.

Dev dependencies matter for build integrity. A compromised dev dependency (build tool, linter, test framework) can modify your build output even though it does not ship to production as a runtime dependency. Audit the full dependency tree, not just dependencies.

Container base images are dependencies too. If your CI uses a custom Docker image, the packages in that image are part of your supply chain. Pin image digests, not tags, and rebuild periodically to incorporate security updates.

Verification Checklist

After implementing these practices, verify:

  • [ ] npm ci is used in CI (not npm install)
  • [ ] npm audit runs on every build and fails on high-severity issues
  • [ ] Lockfile is committed and reviewed in pull requests
  • [ ] CI action versions are pinned to full SHAs
  • [ ] Workflow permissions default to read-only
  • [ ] SBOM is generated and stored with every production build
  • [ ] Deployment secrets are scoped to production environments
  • [ ] Secret scanning is enabled on the repository
  • [ ] Branch protection rules require PR review for main branch
  • [ ] Build output is reproducible (verified by building twice and comparing)
  • [ ] SECURITY.md documents the project's security requirements and practices

Further Reading