Security analysis of lockfile changes across ecosystems — consumes lockdelta output and produces a structured threat report.
When a dependency update lands in a pull request, most CI pipelines verify only that the build succeeds. lockscan runs a deeper pass: it downloads the new (and old) package artifacts, scans them across multiple detection dimensions, and flags anything that matches known supply-chain attack patterns.
lockdelta diff base.lock head.lock | lockscan
The sections below map each detection dimension to the real-world attacks it would have caught, and explain how the detection works technically.
What it detects: Dangerous API calls introduced or increased by a version update.
Every source file inside the downloaded artifact is scanned with a catalogue of regular-expression patterns. The tool reports the full old/new hit count and a delta of patterns that are new in the updated version.
| Label | Pattern | Why it matters |
|---|---|---|
exec:eval |
eval( |
Arbitrary code execution from a string |
exec:Function |
new Function( |
Same as eval, slightly more obfuscated |
exec:vm |
vm.runIn*, vm.Script |
Node sandboxed eval escape |
shell:require-child_process |
require('child_process') |
Importing the shell execution module |
shell:execSync / spawnSync / execFileSync |
— | Direct shell execution |
exec:dynamic-require |
require(process.env…) or require(var +…) |
Module loaded from a computed name — attacker can swap it at runtime |
deser:yaml.load |
yaml.load( |
Unsafe YAML deserialization (use yaml.safeLoad) |
deser:generic |
deserialize( |
Generic unsafe deserialization |
obfusc:eval-decode |
eval(Buffer.from(…)) / eval(atob(…)) |
Decode-then-eval compound — the canonical obfuscation pattern used in flatmap-stream (2018) and ua-parser-js (2021) |
obfusc:base64 |
Buffer.from(…, 'base64') |
Encoded payload |
obfusc:fromCharCode |
String.fromCharCode( |
Character-code obfuscation, used in many npm attacks |
obfusc:hex-escape |
\xNN sequences |
Hex-encoded strings to bypass simple scanners |
obfusc:atob |
atob( |
Base64 decode in browser-targeting code |
net:http / net:fetch / net:axios / net:got |
— | Outbound network I/O — expected in a network library, suspicious in a math/utility package |
fs:writeFileSync / fs:async-write / fs:rmSync / fs:unlinkSync |
— | Filesystem writes and deletes |
info:process.env |
process.env |
Environment variable access |
cred:ci-token |
process.env.GITHUB_TOKEN, AWS_SECRET_ACCESS_KEY, NPM_TOKEN, CI_JOB_TOKEN, CIRCLE_TOKEN, VERCEL_TOKEN, CODECOV_TOKEN, FIREBASE_TOKEN, HEROKU_API_KEY, NODE_AUTH_TOKEN |
Targeted CI credential exfiltration — exact pattern used in event-stream (2018), ctx (2022), dbus-next (2022) |
inject:proto-pollution |
__proto__ assignment or Object.defineProperty(Object.prototype…) |
Prototype pollution enabling property injection across the entire runtime |
| Label | Pattern | Why it matters |
|---|---|---|
exec:eval / exec:exec / exec:compile |
— | String-to-code execution |
exec:__import__ / exec:importlib |
__import__(, importlib.import_module( |
Dynamic import from string — can load modules the static scanner would miss |
exec:dynamic-load-path |
importlib.util.spec_from_file_location( |
Loads a Python module from a filesystem path — used to execute a payload dropped to /tmp |
shell:os.system / shell:os.popen |
— | Shell execution via the OS module |
shell:subprocess |
subprocess.run, Popen, call, check_output |
Full subprocess spawn |
deser:pickle / deser:marshal |
pickle.loads(, marshal.loads( |
Arbitrary code execution hidden in a serialized object |
deser:yaml.load |
yaml.load( without Loader=SafeLoader |
Unsafe YAML deserialization |
obfusc:base64 / obfusc:fromhex / obfusc:hex-escape |
— | Encoded payload techniques |
net:http-lib |
requests, httpx, aiohttp, ftplib, smtplib |
Network I/O libraries |
net:raw-socket |
socket.socket(, socket.connect(, create_connection( |
Raw TCP/UDP connections — unusual outside of networking packages |
fs:file-write / fs:file-delete / fs:dir-delete |
open(…,'w'), os.remove(, shutil.rmtree( |
Filesystem modification and deletion |
native:ctypes-load |
ctypes.CDLL(, ctypes.cdll.LoadLibrary( |
Loads a shared library dynamically — bypasses Python-level analysis entirely |
native:cffi-load |
cffi.FFI(), ffi.dlopen( |
Same via the C Foreign Function Interface |
cred:ci-token |
os.environ[…'GITHUB_TOKEN'], AWS_ACCESS_KEY_ID, PYPI_TOKEN, TWINE_PASSWORD, CODECOV_TOKEN |
Targeted CI credential exfiltration in Python |
What it detects: Malicious code hidden inside compiled extensions (.so, .pyd, .dylib, .dll) that static Python source scanning cannot reach.
Why it matters: PyO3 (Rust) and C/C++ extensions are opaque to source scanners. An attacker distributing a compiled backdoor is invisible to any tool that only reads .py files. This is the threat vector behind the 3CX supply-chain attack (2023), where a legitimate Windows installer was trojaned with a compiled DLL payload.
How it works: For each binary file extracted from a wheel:
- String extraction: Runs of printable ASCII characters (≥ 6 chars) are extracted from the raw binary, mimicking
strings(1). These reveal URLs, IP addresses, file paths, and other artefacts that compiled code cannot hide. - Dangerous native symbols: The extracted strings are matched against a list of libc / Windows API symbols (socket, connect, execve, system, popen, dlopen, CreateProcessA, setuid, mprotect, …). A library that legitimately uses these will already have them in the old version; a new or spiking symbol count is the signal.
- Shannon entropy: A high-entropy region (threshold 7.2 bits/byte) indicates packed or encrypted data — the classic signature of a payload that decrypts itself at runtime.
- Suspicious embedded strings: URLs, IP addresses,
/etc/passwd-style paths,/tmp/…paths, and long base64-like blobs are flagged.
Delta analysis: The tool compares old and new binary scans so that a library that legitimately imports socket does not generate noise — only changes between versions are reported.
What it detects: Different compiled wheels for the same package version carrying different security findings across platforms.
Why it matters: An attacker can publish a benign linux_x86_64 wheel alongside a malicious win_amd64 wheel for the same version. Most scanners only test against one platform. lockscan tests all platforms in the configured matrix and raises a PLATFORM DIVERGENCE warning if the finding sets differ.
What it detects: Code that runs automatically at install time — before the user's application imports anything.
npm executes preinstall, install, postinstall, prepare, and prepublish scripts defined in package.json automatically during npm install. This is the most common supply-chain vector in npm: it requires zero import by the victim.
Attacks caught: Virtually every major npm supply-chain incident has used postinstall scripts: event-stream (2018), eslint-scope (2018), ua-parser-js (2021), node-ipc (2022), colors.js (2022).
The tool diffs hooks between the old and new version and flags any hook that is new or whose command changed.
.pth files placed in a site-packages directory are processed by Python's site module at interpreter startup. Any line that begins with import is executed as Python code — before the user's script runs, before any virtualenv activation, and before any audit hook can fire.
Wheel files may contain executables in *.data/scripts/ that are installed into the Python bin/ directory (alongside pip, python, etc.) and become available system-wide. A malicious script installed here runs whenever the user uses their Python environment.
What it detects: A package version that has no corresponding tagged release in its source repository — or more importantly, a version where the previous version had a release tag but the current one does not.
Why it matters: Legitimate maintainers tag releases. A version published to the registry without a corresponding source tag means the distributed artifact was not built from the tagged source. It may have been built from a modified working tree, a CI pipeline tampered with after the tag, or submitted directly without any source-to-artifact traceability.
The releaseDropped flag specifically targets the case where an attacker takes over an existing package: the old maintainer tagged releases diligently, but the new actor published directly to the registry to avoid leaving a public paper trail.
Supported: GitHub (requires GITHUB_TOKEN for rate limits). GitLab support is a planned addition.
What it detects: Package versions with already-known CVEs or security advisories at the time of the update.
The tool queries osv.dev — the same database used by pip audit and uv audit — using a two-step approach: one batch call to get affected advisory IDs, then parallel fetches for full advisory details. Results include severity (CRITICAL / HIGH / MODERATE / LOW), CVE aliases, and a link to the advisory.
Why it matters: An updated dependency might already be known-compromised before your scan runs. This catches the case where someone pins to a version flagged in OSV.
What it detects: Packages that switched registry source, or whose version number matches the hallmarks of a dependency confusion attack.
Dependency confusion (discovered by Alex Birsan, 2021): if a private package acme-utils exists only in your internal registry, an attacker can publish acme-utils to the public npm / PyPI registry with a very high version number. Package managers that check the public registry first will resolve to the attacker's version.
Detection heuristics:
- Private → public registry switch: The resolved registry URL changed from an internal host to npmjs.org / pypi.org. Flagged as
criticalon its own — this is a real change in provenance. - Major version ≥ 100, or a round high version (e.g. 99.0.0 / 50.0.0): A version number matching the classic confusion pattern. This is a weak signal on its own — legitimate projects ship big round major bumps too (e.g. a library jumping straight to a round marketing version). It only escalates to
criticalwhen corroborated by an actual registry change (above) or a missing matching tag in the package's source repository (see § Repository release matching); otherwise it's surfaced atlowseverity for visibility, not treated as a confirmed finding. - New package added directly from public registry with an unusually high version: Same weak-signal treatment as above — a first-time dependency addition at a modern major version is common and not inherently suspicious.
What it detects: A package version published by a different npm account than all previous versions.
Why it matters: This is the literal mechanism of the event-stream (2018) attack. The original maintainer (dominictarr) transferred the package to a contributor (right9ctrl) who had no prior publishing history for the package. The npm registry records _npmUser per version, making this detectable.
The tool compares _npmUser.name between the old and new version metadata and flags any change.
What it detects: Package versions published within the last 24 hours.
Why it matters: Supply-chain attackers often publish a malicious version just before a scheduled deployment window — the "sniper" pattern. The malicious version may be pulled within hours once discovered, but damage is done to any pipeline that ran during that window. A freshness flag does not mean the package is malicious, but it warrants a manual review before merging.
The tool surfaces versionAgeDays (days since that specific version was published) and raises a FRESH PUBLISH summary warning for any package with versionAgeDays < 1.
What it detects: A package that changed its declared license between the old and new version.
Why it matters: A license change from a permissive license (MIT, Apache 2.0) to a proprietary, copyleft, or "source-available" license can affect your legal compliance posture. It may also be a signal of an ownership change — a new maintainer relicensing to monetize an existing install base (as happened with several HashiCorp and Elastic products).
What it detects: Modifications to build configuration files — the files that control how source code is compiled into a package.
Why it matters: The XZ Utils backdoor (CVE-2024-3094, March 2024) is the most sophisticated supply-chain attack on record. The attacker (Jia Tan, an identity that turned out to be fictitious) spent two years contributing legitimate patches before injecting a backdoor through modifications to configure.ac and Makefile.am. The payload was compiled into liblzma during the build, was absent from the source tree, and bypassed review because it arrived as seemingly routine build-system fixes.
Monitored files:
- Python / general:
setup.py,setup.cfg,pyproject.toml,CMakeLists.txt,configure.ac,configure.in,Makefile.am,Makefile.in - npm native add-ons:
binding.gyp,binding.gyp.json— the build descriptor for native C/C++ Node.js modules. A package that previously shipped pure JavaScript adding abinding.gypnow compiles native code on install.
Any of these files being added, removed, or modified between the old and new version triggers a BUILD SYSTEM FILES CHANGED warning.
What it detects: A Python package that previously shipped only a source distribution (.tar.gz) now includes compiled binary wheels (.whl).
Why it matters: Source distributions are fully readable Python source. Binary wheels contain compiled code that cannot be statically analyzed without reverse-engineering tools. The transition from sdist-only to wheel-distribution is a meaningful security boundary change: the same code audit that worked for the old version is now insufficient for the new one. Several PyPI malware campaigns have used this pattern — publishing a benign sdist to establish a reputation, then adding a malicious wheel in a later version.
For every downloaded artifact, lockscan verifies the cryptographic hash against the value published by the registry:
- PyPI: SHA-256 from the JSON API — the same hash that
pipverifies - npm: SHA-1 (
shasum) from the version metadata, withintegrity(SHA-512) available
A hash mismatch is a hard error — the artifact is rejected and the analysis is aborted for that package.
For Python packages, lockscan needs to pick the right wheel for each target platform. The selection algorithm implements the PEP 425 compatibility tag matching:
- Python tag: Exact CPython version match (e.g.
cp312) scores highest;py3/py2.py3(pure Python) scores lower; wrong CPython version or PyPy is incompatible. - Platform tag: Matches
linux_x86_64/manylinux,macosx_*_arm64/universal2,win_amd64/win_arm64.
Each target platform in the configured matrix gets the best-scoring wheel. Unique artifacts (by SHA-256) are deduplicated, preserving which platforms they serve. If no wheel matches, the sdist is used as a fallback (with binary scanning skipped).
lockscan [options] < diff.json
lockdelta diff base.lock head.lock | lockscan [options]| Option | Description |
|---|---|
--platform <spec> |
Target platform(s). Format: os/arch[/pyver] (e.g. linux/x86_64/3.12, macos/arm64). Repeatable. Defaults to current host. |
--only <types> |
Restrict to added, updated, or removed. Comma-separated. |
--format json|text |
Output format. Default: text. |
| Variable | Effect |
|---|---|
GITHUB_TOKEN |
GitHub personal access token for the repo release check. Without it, the GitHub API rate limit applies (60 requests/hour per IP). |
If no --platform is specified, lockscan detects the current OS and architecture. For the Python version, it reads (in order):
.python-versionin the working directory[tool.python]or[tool.rye]tables inpyproject.toml
lockscan ships as a GitHub Action (lachaib/lockscan). It consumes the JSON diff produced by lockdelta and surfaces findings as step annotations, a step summary, PR comments, and/or a SARIF report.
| Input | Required | Default | Description |
|---|---|---|---|
lockdelta-output |
yes | — | The lockdelta JSON diff. Accepts a raw JSON string (e.g. ${{ steps.lockdelta.outputs.diff }}) or a file path written by lockdelta's json-to-file option. |
github-token |
no | ${{ github.token }} |
GitHub token for the repo release check and PR comment posting. |
platform |
no | host platform | Target platform(s). Format: os/arch[/pyver] (e.g. linux/x86_64/3.12, macos/arm64). Comma- or newline-separated. |
only |
no | all | Restrict analysis to specific change types. Comma-separated: added, updated, removed. |
markdown |
no | false |
Generate a markdown summary and expose it as the summary output. |
markdown-to-file |
no | — | Write the markdown summary to this file path. |
sarif-to-file |
no | — | Write a SARIF 2.1.0 report to this file path. Use with actions/upload-sarif to surface findings in the Security / Code Scanning tab. |
annotate |
no | true |
Emit workflow annotations on the dependency manifest file for each finding. Set to false to disable. |
post-comment |
no | false |
Post or update a PR comment with the markdown summary. true always posts/updates. if-findings posts when findings exist and updates to a "resolved" message when clean. false never posts. |
fail-on |
no | never |
Fail the step when findings at or above this severity are detected: critical, high, any, or never. |
write-summary |
no | true |
Write the markdown summary to the GitHub step summary ($GITHUB_STEP_SUMMARY). Set to false to disable. |
| Output | Description |
|---|---|
report |
Full security report as a JSON string. |
summary |
Markdown summary of findings. Set when markdown: true or post-comment is enabled. |
sarif |
SARIF 2.1.0 report as a JSON string. Only set when sarif-to-file is specified. |
has-findings |
true if any security findings were identified, false otherwise. |
has-critical |
true if any CRITICAL severity findings were identified. |
has-high |
true if any HIGH or CRITICAL severity findings were identified. |
on: [pull_request]
jobs:
lockscan:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: Diff dependencies
id: lockdelta
uses: lachaib/lockdelta@v0.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: lachaib/lockscan@v0.1.0
with:
lockdelta-output: ${{ steps.lockdelta.outputs.diff }} - uses: lachaib/lockscan@v0.1.0
with:
lockdelta-output: ${{ steps.lockdelta.outputs.diff }}
fail-on: high - uses: lachaib/lockscan@v0.1.0
id: lockscan
with:
lockdelta-output: ${{ steps.lockdelta.outputs.diff }}
sarif-to-file: lockscan.sarif
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: lockscan.sarif permissions:
contents: read
pull-requests: write
...
- uses: lachaib/lockscan@v0.1.0
with:
lockdelta-output: ${{ steps.lockdelta.outputs.diff }}
post-comment: if-findings
fail-on: any - uses: lachaib/lockscan@v0.1.0
with:
lockdelta-output: ${{ steps.lockdelta.outputs.diff }}
platform: |
linux/x86_64/3.12
macos/arm64/3.12
linux/arm64/3.12=== LOCKSCAN SECURITY REPORT ===
Generated: 2025-06-09T14:32:01.000Z
Base ref: main
Head ref: feature/upgrade-deps
Analyzed: 12 package(s) — 0 error(s)
New hits: 3 new security pattern hit(s)
⚠ BINARY ANOMALIES: 1 package(s) have suspicious changes in compiled extensions
⚠ KNOWN VULNERABILITIES: 2 known CVE/advisory across changed packages (OSV)
⚠ PUBLISHER CHANGE: 1 package(s) published by a different npm account than before
══════════════════════════════════════════════════════════════
[1/12] some-package UPDATED 1.2.3 → 1.2.4 (direct)
══════════════════════════════════════════════════════════════
VERIFICATION
platforms: linux x86_64 (Python 3.12)
old: some_package-1.2.3-cp312-cp312-manylinux_2_17_x86_64.whl [linux x86_64 (Python 3.12)]
sha256: abc123… STATUS:VERIFIED
new: some_package-1.2.4-cp312-cp312-manylinux_2_17_x86_64.whl [linux x86_64 (Python 3.12)]
sha256: def456… STATUS:VERIFIED
METADATA DELTA
⚠ PUBLISHER CHANGED (different npm account)
author: unchanged
homepage: unchanged
license: unchanged
deps_added: none
deps_removed: none
BINARY SCAN
delta: 2 anomaly(ies)
by type:
native:socket: 1
binary:high-entropy: 1
detail:
libfoo.so — native:socket: new symbol (count: 1)
libfoo.so — binary:high-entropy: entropy 6.91 → 7.34
SECURITY SCAN
old_hits: 2 hit(s)
new_hits: 2 hit(s)
delta: +1 new hit(s) not in old version
import { analyze } from 'lockscan';
import type { DiffReport } from 'lockdelta';
const report = await analyze(diffReport, {
platforms: [{ os: 'linux', arch: 'x86_64', python: '3.12' }],
onlyTypes: ['added', 'updated'],
});
for (const lf of report.lockfiles) {
for (const pkg of lf.packages) {
if (pkg.binaryFindings?.delta.length) {
console.warn(`Binary anomaly in ${pkg.name}@${pkg.newVersion}`);
}
}
}The table below summarises real supply-chain incidents and which detection(s) in lockscan would have flagged them.
| Incident | Year | Vector | lockscan detection |
|---|---|---|---|
| event-stream | 2018 | Maintainer transfer + postinstall payload targeting Bitcoin wallets | Post-install hooks, publisher change, cred:ci-token patterns, obfusc:eval-decode |
| eslint-scope | 2018 | Compromised npm account published malicious version | Publisher change, post-install hooks |
| flatmap-stream | 2018 | Malicious transitive dep with AES-encrypted payload | obfusc:eval-decode, binary entropy (if compiled), post-install hooks |
| ua-parser-js | 2021 | Account hijack, crypto miner + trojan | Publisher change, post-install hooks, obfusc:eval-decode |
| PyTorch nightly | 2022 | Dependency confusion: torchtriton on PyPI shadowed private package |
Registry/confusion check (high version heuristic) |
| ctx / dbus-next | 2022 | Malicious PyPI packages exfiltrating env vars | cred:ci-token, net:http-lib patterns |
| node-ipc (protestware) | 2022 | Maintainer added destructive code targeting Russian/Belarusian IPs | Pattern scan (fs:dir-delete), build system check |
| colors.js / faker.js | 2022 | Maintainer sabotage (infinite loop) | Pattern scan, version freshness |
| 3CX | 2023 | Trojanised Windows installer via compromised upstream | Binary scan (symbol/entropy), platform divergence |
| XZ Utils | 2024 | Build system injection via configure.ac over 2 years |
Build system files changed, binary scan (symbol changes), release dropped |
| Polyfill.io | 2024 | CDN domain acquired, serving malware to sites that depended on it | Registry change, repo check |
- Typosquatting / homoglyph detection: Comparing added package names against a popularity-ranked list (npm top-5000, PyPI top-5000) with Levenshtein distance ≤ 2 is not yet implemented. This would catch campaigns like
crossenv(targetingcross-env) and hundreds of similar attacks. - Transitive dependency analysis:
lockscananalyses direct changes reported bylockdelta. If a trusted package adds a malicious new dependency, the dependency itself is flagged if it appears in the lockfile diff, but the relationship is not visualised. - GitLab / Bitbucket release checks: Only GitHub is supported for repo release matching.