When I was recently asked to strengthen the security posture of a smart contract (SC) project, I faced the emerging challenge: “How to protect against software supply chain attacks in the blockchain space?” Despite extensive research, I found mostly generic security advice rather than concrete, actionable guidance tailored for SCs.
After posting on Stack Overflow (which unfortunately got closed for being “too generic”), I realized the blockchain development community needed more specific resources on this topic. This led me down a path of research and implementation that resulted in a set of security controls that I’m sharing here.
Here’s a bit of context about our project setup:
Core Framework and Libraries:
- Hardhat as our development framework
- OpenZeppelin contracts as our foundation
- TypeScript for scripting and automation
- Ethers.js as our Web3 library
- NPM for package management
Infrastructure and Deployment:
- Multi-developer environment across different machines
- Deployment targets spanning both public and private blockchain platforms
- GitHub-based CI/CD pipeline with automated gates for quality controls, including code reviews, unit tests, and static analysis for all
main
branch merges - Code will be externally audited for security vulnerabilities
Note: While we used Hardhat and Ethers.js, the best practices I’ll discuss are equally applicable to Foundry and Web3.js environments.
Protecting code integrity across our development-to-deployment pipeline was critical, especially given the multiple nodes, platforms, and environments involved. We couldn’t risk malicious code or bytecode compromising our smart contracts. Because smart contract-specific supply chain security guidance was nonexistent, we had to forge our path. Unsurprisingly, many of our solutions mirrored proven software development security practices. Here’s a high-level breakdown of the security controls we implemented and the rationale behind each decision.
Because NPM served as our package manager for Hardhat and OpenZeppelin dependencies, we began by implementing the NPM security guidelines recommended by OWASP and Snyk. These provide comprehensive guidance aimed at preventing supply chain attacks through package management vulnerabilities.
- Pin specific versions of packages
Fix dependencies to exact version numbers instead of allowing version ranges. For instance,"@openzeppelin/contracts": "5.2.0"
rather than"@openzeppelin/contracts": "^5.2.0"
. This approach prevents unintended updates that could introduce unvetted or compromised code through automated package upgrades. Also, it’s good practice to pinnode
andsolc
versions inpackage.json
andhardhat.config.ts
, respectively, as it ensures reproducibility. - Enable strict integrity checks for packages
Always commit and maintain thepackage-lock.json
file to provide cryptographic validation of package contents. This file serves as a security fingerprint for each dependency, enabling detection of any changes during the installation process. Further, usenpm ci
instead ofnpm install
for dependency installation, as it will halt the process if it discovers any discrepancies between the packages being installed and those specified in thepackage-lock.json
file. - Disable running scripts in packages automatically
Prevent NPM from automatically running pre-install, post-install, and other lifecycle scripts from third-party dependencies. This security measure eliminates a frequently exploited attack vector where threat actors inject malicious code into package installation hooks. - Add an .npmrc file to enforce 1 to 3 across the entire repo
Implement a project-level configuration file that standardizes the above security settings for all developers and CI/CD pipeline. This approach guarantees uniform security enforcement regardless of individual machine setups or CI/CD pipeline configurations. Here’s a sample.npmrc
file to include in your project’s root directory:
# Ensures exact version numbers are saved in package.json
save-exact=true # Enables creation of package-lock.json for reproducible installs
package-lock=true
# Reduces noise during install, only shows warnings
loglevel=warn
#Enable strict integrity checks
strict-ssl=true
# Disable running scripts automatically
ignore-scripts=true
# Enable audit on install
audit=true
5. Use a local NPM proxy
You can establish a private package repository and cache using tools like Verdaccio. Alternatively, implement GitHub submodules (though this approach has become less popular). While we evaluated implementing a local NPM proxy for enhanced security, we decided the additional risk was manageable given our occasional need to manually incorporate the latest package versions.
Bytecode integrity can be verified at two critical stages:
- Store and validate bytecode hashes in releases
Generate cryptographic hashes of your compiled bytecode(s) using algorithms like SHA3 or Keccak256, then store these hashes as part of your release artifacts. Before deployment, recalculate the hash of the bytecode you’re about to deploy and verify it matches the stored hash from your release. We choseKeccak256
to calculate hash values, as it’s native to EVM-based platforms and readily available in both Ethers.js and Web3.js libraries. While SHA3 remains a viable alternative, MD5 should be avoided due to its known cryptographic vulnerabilities. - Verify deployed contracts against source bytecode
Leverage built-in verification capabilities from tools like Hardhat and Etherscan.io to validate that your deployed on-chain bytecode matches either your source code or the compiled bytecode stored in yourcontract_name.json
files. Keep in mind that Hardhat uses Etherscan or Sourcify for verification, requiring your deployed contracts to be accessible/visible to these platforms.
For private blockchain deployments, you can create custom Ethers.js or Web3.js scripts to download the on-chain bytecode and compare it against your local contract_name.json
file. When performing manual verification, remember to strip the metadata that gets appended to on-chain contract bytecode (see https://playground.sourcify.dev/), and note that deployed contracts don’t include constructor bytecode. For proxy implementations (especially UUPS proxies), additional verification steps may be required depending on the proxy pattern used. I’ll cover these specific scenarios in detail in an upcoming post.
What security measures have worked best in your development workflow? Are there any supply chain vulnerabilities I missed, or innovative solutions you’ve implemented? Drop your thoughts below.