Safeguard
Application Security

Wiring dependency and SAST scanning into your JavaScript CLI workflow

npm audit has shipped for free since npm 6 in 2018, yet most JavaScript teams still find out about vulnerable dependencies in a Slack alert, not a failed commit.

Safeguard Research Team
Research
7 min read

npm audit has been sitting in every Node.js developer's terminal since npm 6 shipped in 2018, reading package-lock.json against what's now the GitHub Advisory Database, yet the vast majority of JavaScript teams still treat dependency scanning as something CI does to them after the fact rather than something they run before git commit. That gap has a cost: the September 2025 Shai-Hulud npm worm compromised hundreds of packages by harvesting credentials from developer machines and self-propagating through maintainers' own publish tokens, and the October 2021 hijacks of ua-parser-js, coa, and rc — packages with tens of millions of weekly downloads combined — injected cryptomining and credential-stealing payloads directly into transitive dependency trees. Neither required a novel exploit; both required only that nobody was scanning locally before the code shipped. This post is a practical, command-level guide to building that local workflow — npm audit and its exit codes, ESLint security plugins and Semgrep for SAST, secrets scanning, and pre-commit gating with Husky — plus what changes when you move the same checks into CI.

Why isn't npm audit enough on its own?

npm audit isn't enough on its own because it only does software composition analysis (SCA) — matching your resolved dependency tree against a known-vulnerability database — and says nothing about bugs in the code your own team writes. It reads package-lock.json (or npm-shrinkwrap.json), which is why a missing or stale lockfile produces non-reproducible results: floating semver ranges resolve differently on every install, so two runs of the same scan on the same package.json can report different findings. npm audit also can't see a SQL-injection sink or an eval() call fed by request input in your own src/ directory — that requires static application security testing (SAST), typically via eslint-plugin-security for lightweight AST-level checks or Semgrep for pattern-based rules with broader language and framework coverage. A complete local workflow needs both layers: SCA for what you depend on, SAST for what you wrote, run together so neither blind spot ships.

How do you actually gate a commit on npm audit results?

You gate a commit on npm audit results by relying on its exit code, since npm audit returns a non-zero status whenever it finds vulnerabilities at or above a chosen severity threshold — the mechanism that makes it usable as a pre-commit or CI gate instead of just a report you read manually. Running npm audit --audit-level=high exits 0 if nothing at high or critical severity is found, and non-zero otherwise, which Husky can wire straight into a pre-commit hook: add "pre-commit": "npm audit --audit-level=high" to a Husky config and a developer's commit simply fails locally instead of merging a critical CVE into main. npm audit signatures, available since provenance publishing landed in npm CLI 9.5.0, adds a second, orthogonal check — it verifies registry-signed package integrity and Sigstore-backed provenance attestations recorded in the public Rekor transparency log, catching tampered or unsigned packages that a CVE-matching audit wouldn't flag at all. Running both in the same hook covers "known vulnerable" and "not actually what it claims to be."

What does a SAST pass add for JavaScript specifically?

A SAST pass adds detection for injection, unsanitized DOM writes, and unsafe deserialization patterns that live in first-party code, which no dependency database will ever catch because the vulnerable line was never published to npm. eslint-plugin-security runs as part of your existing lint step and flags patterns like child_process.exec() with a non-literal argument or new RegExp() built from user input — cheap, fast, and already inside most teams' CI because ESLint is already there. eslint-plugin-no-unsanitized targets a narrower but common bug class: assignments to innerHTML, outerHTML, and document.write() that bypass Angular's or a framework's built-in sanitization. Semgrep CLI (semgrep --config auto) goes further, applying community and OWASP-aligned rule sets across JavaScript, TypeScript, and JSX with taint-tracking-lite rules, and it emits SARIF output natively, so the same command that runs on a developer's laptop also uploads findings to GitHub code scanning in CI without any format translation.

Where do secrets scanning and lockfiles fit into the same pipeline?

Secrets scanning and lockfiles fit into the same pipeline as a prerequisite and a companion check, not an afterthought bolted on later. gitleaks and git-secrets both run as pre-commit hooks scanning the actual diff for API-key and credential patterns before they ever reach a remote — cheaper by orders of magnitude than rotating a leaked key after a GitHub push, and the reason most teams add it to the same Husky hook as npm audit rather than a separate step. Lockfiles matter for a subtler reason: package-lock.json at lockfile version 3, standard since npm 7, is what makes an SCA scan deterministic across machines and CI runners in the first place — without a committed lockfile, npm ci isn't even available, and npm install can silently resolve a newer, unaudited transitive version on every run. Committing the lockfile and running npm ci (not npm install) in both the pre-commit hook and CI is the detail that makes every other check in this workflow reproducible rather than approximate.

How does this look different once it moves into CI?

Once this moves into CI, the local checks become gates with actual enforcement power instead of a developer's optional habit, and depth of dependency resolution starts to matter more than it does locally. Safeguard's own CLI splits the same way: a local development loop runs a fast, readable safeguard scan against the working directory, while a CI pipeline runs a stricter policy-gated scan and fails the build on a non-zero exit code, the same pattern as the npm audit gating described above but applied across a wider set of scanners in one pass. The reason depth matters in CI specifically: a typical Node.js service might have 30 direct dependencies fanning out to roughly 300 packages at level two and into the tens of thousands by level four to six, and Safeguard advertises transitive dependency resolution to 100 levels — deeper than the roughly 60-level depth it cites for unnamed competitors — specifically because incidents like the Shai-Hulud worm propagated through transitive chains that shallow scans in a quick local check are never going to reach in the time budget a pre-commit hook allows.

What's a reasonable minimum setup to start with today?

A reasonable minimum setup is three commands wired into one Husky pre-commit hook and mirrored in CI: npm audit --audit-level=high for known-vulnerability gating, `npx eslint . --config .eslintrc-security.json` (or semgrep --config auto) for first-party SAST, and gitleaks detect --staged for secrets, all running against a committed package-lock.json so results are reproducible between a laptop and a CI runner. None of these tools requires a paid license to start, and all of them return exit codes a CI system already understands. The upgrade path from there — SBOM generation in CycloneDX format, reachability analysis that filters which of the flagged CVEs are actually callable from your entry points, and unified policy gates across dependency, SAST, and container findings — is where a connected platform like Safeguard replaces four separately-maintained CLI tools with one safeguard scan call, but the free local baseline above is enough to stop the two 2021 npm hijacks and most of what Shai-Hulud tried before either ever reaches production.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.