Prepared by:
HALBORN
Last Updated 04/25/2024
Date of Engagement by: June 23rd, 2023 - August 4th, 2023
100% of all REPORTED Findings have been addressed
All findings
9
Critical
2
High
0
Medium
4
Low
1
Informational
2
Irrigation Protocol engaged Halborn to conduct a security assessment on their smart contracts beginning on June 23rd, 2023 and ending on August 4th, 2023. The security assessment was scoped to the smart contracts provided in the following GitHub repositories:
The team at Halborn was provided six weeks for the engagement and assigned a full-time security engineer to verify the security of the smart contracts. The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.
The purpose of this assessment is to:
Ensure that smart contract functions operate as intended.
Identify potential security issues with the smart contracts.
In summary, Halborn identified some security risks that were mostly addressed by the Irrigation Protocol team
.
Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of this assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of the code and can quickly identify items that do not follow the security best practices. The following phases and associated tools were used during the assessment:
Research into architecture and purpose.
Smart contract manual code review and walkthrough.
Graphing out functionality and contract logic/connectivity/functions. (solgraph
)
Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes.
Manual testing by custom scripts.
Scanning of solidity files for vulnerabilities, security hot-spots or bugs. (MythX
)
Static Analysis of security for scoped contract, and imported functions. (Slither
)
Testnet deployment. (Foundry
)
**1. IN-SCOPE TREE & COMMIT : **
The security assessment was scoped to the following smart contracts:
GitHub repository: IrrigationProtocol/irrigation-contracts-diamond Commit ID: 159927ff1e8f8e212cd09894c29c1019e5d417f6 Remediation Commit ID: 1b9420fb77d856bb57a35e947ae255b035e3037c Smart contracts in scope:
SprinklerUpgradeable.sol
WaterTowerUpgradeable.sol
TrancheBondUpgradeable.sol
AuctionUpgradeable.sol
ERC1155WhitelistUpgradeable.sol
WaterCommonUpgradeable.sol
PriceOracleUpgradeable.sol
PodsOracleUpgradeable.sol
WaterUpgradeable.sol
SprinklerStorage.sol
WaterTowerStorage.sol
TrancheBondStorage.sol
AuctionStorage.sol
ERC1155WhitelistStorage.sol
WaterCommonStorage.sol
PriceOracleStorage.sol
IrrigationDiamond.sol
Diamond.sol
DiamondCutFacet.sol
DiamondLoupeFacet.sol
EIP2535Initializable.sol
IrrigationAccessControl.sol
OwnershipFacet.sol
FullMath.sol
PodTransferHelper.sol
LibPrice.sol
BeanPriceOracle.sol
ChainlinkOracle.sol
UniswapV3Twap.sol
EXPLOITABILIY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
2
High
0
Medium
4
Low
1
Informational
2
Security analysis | Risk level | Remediation Date |
---|---|---|
SPRINKLERUPGRADEABLE CONTRACT CAN BE DRAINED THROUGH THE EXCHANGETOWATER FUNCTION | Critical | Solved - 08/21/2023 |
AUCTIONS AND BIDS COULD BE STUCK PERMANENTLY IN THE AUCTIONUPGRADEABLE CONTRACT IF A BID IS PLACED BY A BLACKLISTED USDC/USDT USER | Critical | Solved - 08/21/2023 |
BIDS CAN BE DOS'ED BY PLACING A VERY HIGH BID ON A VERY SMALL BIDAMOUNT | Medium | Solved - 08/21/2023 |
LATESTANSWER CALL MAY RETURN STALE RESULTS | Medium | Solved - 08/21/2023 |
USER COULD CANCEL THE REST OF BIDS OF AN AUCTION BY DOING 200 DIFFERENT BIDS | Medium | Solved - 08/21/2023 |
SWAPETHFORWATER CALL CAN BE SANDWICHED | Medium | Solved - 08/21/2023 |
AUTOIRRIGATECALL WILL ALWAYS REVERT WITH OVERFLOW IF IT'S CALLED WITH THE FULL REWARDAMOUNT | Low | Risk Accepted - 08/21/2023 |
LACK OF A DOUBLE-STEP TRANSFEROWNERSHIP PATTERN | Informational | Acknowledged - 08/21/2023 |
CALLING SETMIDDLEASSET WOULD CAUSE THE IRRIGATION BONUS TO ALWAYS BE ZERO | Informational | Acknowledged - 08/21/2023 |
// Critical
In the SprinklerUpgradeable
contract, a set of assets are whitelisted which can be used to be swapped for WATER. The assets whitelisted can be seen here:
Native Ether
represented by the address:
0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeENative Ether, represented by the 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE
address, is used to check the multiplier assigned to the Native Ether exchanges:
/**
* @notice Exchange ETH to water
* @return waterAmount received water amount
*/
function exchangeETHToWater() external payable nonReentrant returns (uint256 waterAmount) {
require(msg.value != 0, "Invalid amount");
waterAmount = getWaterAmount(Constants.ETHER, msg.value);
if (waterAmount > sprinkleableWater()) revert InsufficientWater();
require(waterAmount != 0, "No water output"); // if price is 0 or tokenMultiplier is 0, amount can be 0
transferWater(waterAmount);
SprinklerStorage.layout().reserves[Constants.ETHER] += msg.value;
emit WaterExchanged(msg.sender, Constants.ETHER, msg.value, waterAmount, false);
}
function getWaterAmount(
address _token,
uint256 _amount
) public view returns (uint256 waterAmount) {
uint256 multiplier = tokenMultiplier(_token);
uint256 tokenPrice = IPriceOracleUpgradeable(address(this)).getPrice(_token);
uint256 waterPrice = IPriceOracleUpgradeable(address(this)).getWaterPrice();
waterAmount = (_amount * tokenPrice * multiplier) / waterPrice;
}
The function exchangeTokenToWater()
is used to exchange whitelisted assets for WATER:
/**
* @notice Exchange whitelisted asset(BEAN, BEAN:3CRV, Spot, and so on) to water
* @param token source token address
* @param amount source token amount
* @return waterAmount received water amount
*/
function exchangeTokenToWater(
address token,
uint256 amount
) external onlyListedAsset(token) nonReentrant returns (uint256 waterAmount) {
require(token != address(this), "Invalid token");
require(amount != 0, "Invalid amount");
waterAmount = getWaterAmount(token, amount);
if (waterAmount > sprinkleableWater()) revert InsufficientWater();
require(waterAmount != 0, "No water output"); // if price is 0, amount can be 0
TransferHelper.safeTransferFrom(token, msg.sender, address(this), amount);
transferWater(waterAmount);
SprinklerStorage.layout().reserves[token] += amount;
emit WaterExchanged(msg.sender, token, amount, waterAmount, false);
}
This function makes use of the TransferHelper
library:
function safeTransferFrom(address token, address from, address to, uint value) internal {
// bytes4(keccak256(bytes('transferFrom(address,address,uint256)')));
(bool success, bytes memory data) = token.call(abi.encodeWithSelector(0x23b872dd, from, to, value));
require(success && (data.length == 0 || abi.decode(data, (bool))), 'STF');
}
Although, this library, unlike OpenZeppelin's SafeERC20, does not check that the token
address is actually a smart contract with some code deployed in that address (&& address(token).code.length > 0
):
/**
* @dev Transfer `value` amount of `token` from `from` to `to`, spending the approval given by `from` to the
* calling contract. If `token` returns no value, non-reverting calls are assumed to be successful.
*/
function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal {
_callOptionalReturn(token, abi.encodeCall(token.transferFrom, (from, to, value)));
}
function _callOptionalReturnBool(IERC20 token, bytes memory data) private returns (bool) {
// We need to perform a low level call here, to bypass Solidity's return data size checking mechanism, since
// we're implementing it ourselves. We cannot use {Address-functionCall} here since this should return false
// and not revert is the subcall reverts.
(bool success, bytes memory returndata) = address(token).call(data);
return success && (returndata.length == 0 || abi.decode(returndata, (bool))) && address(token).code.length > 0;
}
Based on this and considering that the 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE
address is a representative address with no actual code deployed in it, a malicious user could call SprinklerUpgradeable.exchangeTokenToWater("0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", <very high amount>)
and drain all the WATER tokens from the contract.
Exploit
:
Debugged call
:
SOLVED: The Irrigation Protocol team
solved the issue by implementing the recommended solution.
Commit ID :
2a75e858618864b3962c623035cca5b5aef4ff4d.
// Critical
The AuctionUpgradeable
contract allows creating different auctions through the createAuction()
function:
function createAuction(
uint96 startTime,
uint96 duration,
address sellToken,
uint256 trancheIndex,
uint128 sellAmount,
uint128 minBidAmount,
uint128 fixedPrice,
uint128 priceRangeStart,
uint128 priceRangeEnd,
AuctionType auctionType
) external payable returns (uint256)
Once an auction is completed, the closeAuction()
function will be called and will distribute the sellToken
between all the bidders. As currently USDC contains a blacklist of users which cannot transfer or receive this token, the following issue could occur:
sellToken
).purchaseToken
like DAI.closeAuction()
is called, but it reverts every time as the USDC transfer to the blacklisted USDC user is forbidden.The auction USDC sellTokens
are now stuck in the contract. All the bids are also stuck permanently in the contract.
Similar issue can occur with USDT as it also contains a blacklist of users.
SOLVED: The Irrigation Protocol team
solved the issue by enforcing a whitelist on the sellTokens
. The whitelist will not include blacklistable tokens like USDC/USDT.
Commit ID :
cae1d1718ef504f4fc27ba1aa464600a71ae635a.
// Medium
As mentioned before, the AuctionUpgradeable
contract allows creating different auctions through the createAuction()
function:
function createAuction(
uint96 startTime,
uint96 duration,
address sellToken,
uint256 trancheIndex,
uint128 sellAmount,
uint128 minBidAmount,
uint128 fixedPrice,
uint128 priceRangeStart,
uint128 priceRangeEnd,
AuctionType auctionType
) external payable returns (uint256)
The minBidAmount
parameter sets the minimum amount of the token that can be bid. In the case that the auctioneer sets the minBidAmount
to a very low value, i.e. 1, the following attack vector would be possible:
minBidAmount
to 1. The auctionType
is a TimedAuction
.placeBid(1, 1, DAI, 1000e18)
bidding 0.01 DAI for 0.000001 USDC. (bidPrice = 1000 DAI).placeBid(1, 100e6, DAI, 1e18)
bidding 100 DAI for 100 USDC. (bidPrice = 1 DAI) but it reverts with a low Bid
error.SOLVED: The Irrigation Protocol team
solved the issue by implementing the recommended solution.
Commit ID :
0a94a93c0f191c992e9f75a41d630ae3dbf80de8.
// Medium
In the ChainlinkOracle
contract, the function getChainlinkPrice()
is used to retrieve prices from different Chainlink aggregators:
/// @dev returns price with decimals 18
function getChainlinkPrice(AggregatorV2V3Interface feed) internal view returns (uint256) {
// Chainlink USD-denominated feeds store answers at 8 decimals
uint256 decimalDelta = uint256(18) - feed.decimals();
// Ensure that we don't multiply the result by 0
if (decimalDelta > 0) {
return uint256(feed.latestAnswer()) * 10 ** decimalDelta;
} else {
return uint256(feed.latestAnswer());
}
}
According to the Chainlink's documentation this function is deprecated and should not be used. Moreover, using latestAnswer()
could lead to return invalid/stale prices.
SOLVED: The Irrigation Protocol team
solved the issue by implementing the recommended solution.
Commit ID :
57d3945089a52425b91cdc8005b62b73d5648c68.
// Medium
In the AuctionUpgradeable
contract, the internal function _settleAuction()
is called every time an auction is closed:
function _settleAuction(uint256 auctionId) internal {
AuctionData memory auction = AuctionStorage.layout().auctions[auctionId];
uint256 trancheIndex = auction.trancheIndex;
// when there are no bids, all token amount will be transfered back to seller
if (auction.curBidId == 0) {
if (trancheIndex == 0) {
TransferHelper.safeTransfer(auction.sellToken, auction.seller, auction.reserve);
} else {
IERC1155Upgradeable(address(this)).safeTransferFrom(
address(this),
auction.seller,
trancheIndex,
auction.reserve,
Constants.EMPTY
);
}
AuctionStorage.layout().auctions[auctionId].reserve = 0;
AuctionStorage.layout().auctions[auctionId].status = AuctionStatus.Closed;
emit AuctionClosed(auction.reserve, auctionId);
return;
}
uint128 availableAmount = auction.reserve;
uint256 settledBidCount = 0;
uint256 curBidId = auction.curBidId;
do {
Bid memory bid = AuctionStorage.layout().bids[auctionId][curBidId];
uint128 settledAmount = _settleBid(
auction.sellToken,
auction.trancheIndex,
auction.seller,
bid,
availableAmount
);
availableAmount -= settledAmount;
AuctionStorage.layout().bids[auctionId][curBidId].bCleared = true;
--curBidId;
++settledBidCount;
} while (
curBidId > 0 &&
settledBidCount <= MAX_CHECK_BID_COUNT &&
availableAmount >= auction.minBidAmount
);
if (availableAmount > 0) {
if (auction.assetType == AssetType.ERC20) {
TransferHelper.safeTransfer(auction.sellToken, auction.seller, availableAmount);
} else {
IERC1155Upgradeable(address(this)).safeTransferFrom(
address(this),
auction.seller,
trancheIndex,
availableAmount,
Constants.EMPTY
);
}
}
AuctionStorage.layout().auctions[auctionId].reserve = 0;
AuctionStorage.layout().auctions[auctionId].status = AuctionStatus.Closed;
emit AuctionClosed(availableAmount, auctionId);
}
This function iterates over all the bids, starting with the last one, transferring the purchased tokens to the bidder until reaching MAX_CHECK_BID_COUNT
(which is hardcoded to 200). The rest of the bids will be automatically cancelled.
Based on this, the following attack vector could be possible:
AuctionUpgradeable(irrigationdiamond).createAuction(
uint96(block.timestamp),
uint96(86400 * 7),
address(USDC),
0,
1000e6,
1,
1e18,
1,
2e18,
AuctionType.TimedAuction
);
SOLVED: The Irrigation Protocol team
solved the issue by implementing the recommended solution.
Commit ID :
c269697ae4b7997abec4c836d46e3f73f292af03.
// Medium
In the WaterTowerUpgradeable
contract, the function _swapEthForWater()
is used to swap Ether for Beans using the Curve Router:
function _swapEthForWater(uint256 amount) internal returns (uint256 waterAmount) {
if (WaterTowerStorage.layout().middleAssetForIrrigate == Constants.BEAN) {
/// @dev swap ETH for BEAN using curve router
address[9] memory route = [
Constants.ETHER,
Constants.TRI_CRYPTO_POOL,
Constants.USDT,
Constants.CURVE_BEAN_METAPOOL,
Constants.BEAN,
Constants.ZERO,
Constants.ZERO,
Constants.ZERO,
Constants.ZERO
];
uint256[3][4] memory swapParams = [
[uint(2), 0, 3],
[uint(3), 0, 2],
[uint(0), 0, 0],
[uint(0), 0, 0]
];
uint256 beanAmount = ICurveSwapRouter(Constants.CURVE_ROUTER).exchange_multiple{
value: amount
}(route, swapParams, amount, 0);
waterAmount = ISprinklerUpgradeable(address(this)).getWaterAmount(
Constants.BEAN,
beanAmount
);
}
}
This function is calling the exchange_multiple
function with no prevention against slippage:
@external
@payable
def exchange_multiple(
_route: address[9],
_swap_params: uint256[3][4],
_amount: uint256,
_expected: uint256,
_pools: address[4]=[ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS],
_receiver: address=msg.sender
) -> uint256:
Based on this, any autoIrrigate()
or irrigate()
call could be sandwiched. A malicious user could be monitoring the mempool for any autoIrrigate()
or irrigate()
call and:
autoIrrigate()
or irrigate()
call to buy beans from the Curve pool. This pushes the price of Bean in the pool up.autoIrrigate()
or irrigate()
call is executed and Beans are bought at a higher price. This raises, once again, the Bean price.autoIrrigate()
or irrigate()
call and sells the Beans at a higher price for a profit.SOLVED: The Irrigation Protocol team
solved the issue by implementing the recommended solution.
Commit ID :
f573cdc385d11a8ab3b8929e97678ace5221e4d7.
// Low
In the WaterTowerUpgradeable
contract, the function autoIrrigate()
is used to auto-compound the user rewards:
function autoIrrigate(address user, uint256 rewardAmount) external onlySuperAdminRole {
if (!WaterTowerStorage.layout().users[user].isAutoIrrigate) revert NotAutoIrrigate();
_irrigate(user, rewardAmount);
/// @dev 870391 is the gasLimit for this function
uint256 gasFee = 870391 * tx.gasprice;
WaterTowerStorage.layout().users[user].pending -= gasFee;
emit AutoIrrigate(user, rewardAmount, gasFee);
}
As this function is called by the protocol admins, the gas fee paid by the admins is subtracted from the user pending balance. Although, the pending balance of the user will always be zero after an _irrigate()
call of the full reward amount. Hence, if autoIrrigate()
is called with the full reward amount the call will always revert.
RISK ACCEPTED: The Irrigation Protocol team
accepted this risk, as they state that the autoIrrigate()
function will always be called by an admin using full reward amount - fee amount
.
// Informational
The OwnershipFacet
contract allows transferring the ownership of the IrrigationDiamond
in a single step:
function transferOwnership(address _newOwner) external override {
LibDiamond.enforceIsContractOwner();
LibDiamond.setContractOwner(_newOwner);
}
If the nominated EOA account is not a valid account, it is entirely possible that the owner may accidentally transfer ownership to an uncontrolled account, losing the access to all functions with the onlySuperAdminRole
modifier.
ACKNOWLEDGED: The Irrigation Protocol team
acknowledged this finding.
// Informational
The contract WaterTowerUpgradeable
contains the function setMiddleAsset()
:
function setMiddleAsset(address middleAsset) external onlySuperAdminRole {
WaterTowerStorage.layout().middleAssetForIrrigate = middleAsset;
}
If this function is ever called to set as middleAssetForIrrigate
an asset different from the BEAN token, the irrigation bonus would always be zero as currently the _swapEthForWater()
would only work if WaterTowerStorage.layout().middleAssetForIrrigate == Constants.BEAN
:
function _swapEthForWater(uint256 amount) internal returns (uint256 waterAmount) {
if (WaterTowerStorage.layout().middleAssetForIrrigate == Constants.BEAN) {
/// @dev swap ETH for BEAN using curve router
address[9] memory route = [
Constants.ETHER,
Constants.TRI_CRYPTO_POOL,
Constants.USDT,
Constants.CURVE_BEAN_METAPOOL,
Constants.BEAN,
Constants.ZERO,
Constants.ZERO,
Constants.ZERO,
Constants.ZERO
];
uint256[3][4] memory swapParams = [
[uint(2), 0, 3],
[uint(3), 0, 2],
[uint(0), 0, 0],
[uint(0), 0, 0]
];
uint256 beanAmount = ICurveSwapRouter(Constants.CURVE_ROUTER).exchange_multiple{
value: amount
}(route, swapParams, amount, 0);
waterAmount = ISprinklerUpgradeable(address(this)).getWaterAmount(
Constants.BEAN,
beanAmount
);
}
}
ACKNOWLEDGED: The Irrigation Protocol team
acknowledged this finding.
Halborn used automated testing techniques to enhance the coverage of certain areas of the smart contracts in scope. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified the smart contracts in the repository and was able to compile them correctly into their ABIs and binary format, Slither was run against the contracts. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.
SprinklerUpgradeable.sol
WaterTowerUpgradeable.sol
TrancheBondUpgradeable.sol
AuctionUpgradeable.sol
ERC1155WhitelistUpgradeable.sol
WaterCommonUpgradeable.sol
PriceOracleUpgradeable.sol
PodsOracleUpgradeable.sol
WaterUpgradeable.sol
IrrigationDiamond.sol
Diamond.sol
DiamondCutFacet.sol
DiamondLoupeFacet.sol
EIP2535Initializable.sol
IrrigationAccessControl.sol
OwnershipFacet.sol
PodTransferHelper.sol
LibPrice.sol
BeanPriceOracle.sol
ChainlinkOracle.sol
UniswapV3Twap.sol
The unprotected initialize issues flagged by Slither were checked individually and are false positives.
No major issues found by Slither.
Halborn used automated security scanners to assist with detection of well-known security issues and to identify low-hanging fruits on the targets for this engagement. Among the tools used was MythX, a security analysis service for Ethereum smart contracts. MythX performed a scan on the smart contracts and sent the compiled results to the analyzers to locate any vulnerabilities.
SprinklerUpgradeable.sol
WaterTowerUpgradeable.sol
TrancheBondUpgradeable.sol
AuctionUpgradeable.sol
ERC1155WhitelistUpgradeable.sol
WaterCommonUpgradeable.sol
No issues found by MythX.
PriceOracleUpgradeable.sol
PodsOracleUpgradeable.sol
WaterUpgradeable.sol
No issues found by MythX.
IrrigationDiamond.sol
Diamond.sol
DiamondCutFacet.sol
DiamondLoupeFacet.sol
EIP2535Initializable.sol
IrrigationAccessControl.sol
OwnershipFacet.sol
PodTransferHelper.sol
LibPrice.sol
No issues found by MythX.
BeanPriceOracle.sol
No issues found by MythX.
ChainlinkOracle.sol
UniswapV3Twap.sol
MythX flagged some integer overflows and underflows which all were false positives, as the contracts are using Solidity ^0.8.17
version. After the Solidity version 0.8.0 Arithmetic operations revert to underflow and overflow by default.
MythX also flagged some assert violations, which were all considered to be false positives.
No major issues were found by MythX.
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.
// Download the full report
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed