diff --git a/.github/workflows/checkAuditRequired.yml b/.github/workflows/checkAuditRequired.yml deleted file mode 100644 index fd0fce09e..000000000 --- a/.github/workflows/checkAuditRequired.yml +++ /dev/null @@ -1,117 +0,0 @@ -# Audit Requirement Checker -# - checks if an audit is required for a given PR -# - an audit is required if any .sol file in path 'src/' has been modified or added -# - if audit is required, the action will assign the label "AuditRequired", otherwise it will assign label "AuditNotRequired" -# - it will also make sure that at the end, exactly one of these two labels is indeed assigned - -name: Audit Requirement Check - -on: - pull_request: - types: [opened, synchronize, reopened] - -jobs: - check-audit-required: - if: ${{ github.event.pull_request.draft == false }} # will only run once the PR is in "Ready for Review" state - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 ##### Fetch all history for all branches - - - name: Remove existing 'AuditRequired' and 'AuditNotRequired' labels - uses: actions-ecosystem/action-remove-labels@v1 - with: - github_token: ${{ secrets.GIT_ACTIONS_BOT_PAT_CLASSIC }} - labels: | - AuditRequired - AuditNotRequired - number: ${{ github.event.pull_request.number }} - - - name: Check Git Diff for protected contracts - id: check_eligibility - run: | - - ##### get all files modified by this PR - FILES=$(git diff --name-only origin/main HEAD) - - ##### make sure that there are modified files - if [[ -z $FILES ]]; then - echo -e "\033[31mNo files found. This should not happen. Please check the code of the Github action. Aborting now.\033[0m" - echo "CONTINUE=false" >> $GITHUB_ENV - fi - - ##### Initialize empty variables - PROTECTED_CONTRACTS="" - - ##### go through all modified file names/paths and identify contracts with path 'src/*' - while IFS= read -r FILE; do - if echo "$FILE" | grep -E '^src/.*\.sol$'; then - ##### contract found - PROTECTED_CONTRACTS="${PROTECTED_CONTRACTS}${FILE}"$'\n' - fi - done <<< "$FILES" - - ##### if none found, exit here as there is nothing to do - if [[ -z "$PROTECTED_CONTRACTS" ]]; then - echo -e "\033[32mNo protected contracts found in Git Diff.\033[0m" - echo -e "\033[32mAssigning label 'AuditNotRequired' to this PR.\033[0m" - echo "AUDIT_REQUIRED=false" >> $GITHUB_ENV - exit 0 - else - echo -e "\033[31mProtected contracts found in Git Diff.\033[0m" - echo -e "\033[31mAssigning label 'AuditRequired' to this PR.\033[0m" - echo "AUDIT_REQUIRED=true" >> $GITHUB_ENV - fi - - echo "PROTECTED_CONTRACTS: $PROTECTED_CONTRACTS" - - ##### Write filenames to temporary files (using variables here was causing issues due to the file names) - echo -e "$PROTECTED_CONTRACTS" > protected_contracts.txt - - - name: Assign correct label based on check outcome - uses: actions-ecosystem/action-add-labels@v1 - id: assign_label - with: - github_token: ${{ secrets.GIT_ACTIONS_BOT_PAT_CLASSIC }} # we use the token of the git action user so the label protection check will pass - labels: ${{ env.AUDIT_REQUIRED == 'true' && 'AuditRequired' || 'AuditNotRequired' }} # if the action made it until here and CONTINUE was true then all checks passed. It CONTINUE was false then no audit is required - number: ${{ github.event.pull_request.number }} - - - name: Verify label assignments (make sure exactly one of the two labels is assigned) - env: - GITHUB_TOKEN: ${{ secrets.GIT_ACTIONS_BOT_PAT_CLASSIC }} - run: | - - echo "Fetching currently assigned labels..." - assigned_labels=$(gh pr view ${{ github.event.pull_request.number }} --json labels --jq '.labels | map(.name) | .[]') - - echo "Assigned labels: $assigned_labels" - - audit_required_assigned=0 - audit_not_required_assigned=0 - - ##### go through all assigned labels and count how many protected labels are found - for label in $assigned_labels; do - if [ "$label" = "AuditRequired" ]; then - audit_required_assigned=$((audit_required_assigned + 1)) - elif [ "$label" = "AuditNotRequired" ]; then - audit_not_required_assigned=$((audit_not_required_assigned + 1)) - fi - done - - total_labels_assigned=$((audit_required_assigned + audit_not_required_assigned)) - echo "Total labels assigned: $total_labels_assigned" - - ##### make sure that exactly (only) one protected label is assigned - if [ "$total_labels_assigned" -ne 1 ]; then - echo -e "\033[31mError: Exactly one of the two labels should be assigned but found $total_labels_assigned assigned labels.\033[0m" - exit 1 - else - echo -e "\033[32mVerified that exactly one label is assigned.\033[0m" - echo -e "\033[32mAll good :)\033[0m" - fi - - echo -e "\033[31mGit Action completed successfully\033[0m" diff --git a/.github/workflows/verifyAudit.yml b/.github/workflows/verifyAudit.yml new file mode 100644 index 000000000..91f7efe8b --- /dev/null +++ b/.github/workflows/verifyAudit.yml @@ -0,0 +1,404 @@ +name: Audit Verifier +# - checks if an audit is required and assigns a (protected) label based on the result ('AuditRequired' or 'AuditNotRequired') +# - if an audit is required, it will verify that the audit was actually completed and then assign label "AuditCompleted" +# - verification includes: +# - ensuring the audit log contains an entry for all added/modified contracts in their latest version +# - ensuring that an audit report has been added +# - ensuring that the PR is approved by the auditor (uses auditor git handle from audit log) +# - ensuring that the commit hash that was audited is actually part of this PR +# - if "AuditCompleted" was assigned earlier but conditions are not met anymore due to changes submitted after the check, label "AuditCompleted" will be removed by this action + +# KNOWN LIMITATIONS +# - will only check the last 100 commits for any matches with audit commit hashes +# - only one audit can be registered per contract (in a specific version) + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +jobs: + verify-audit: + # will only run once the PR is in "Ready for Review" state + if: ${{ github.event.pull_request.draft == false }} + runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ secrets.GIT_ACTIONS_BOT_PAT_CLASSIC }} + AUDIT_LOG_PATH: 'audit/auditLog.json' + PR_NUMBER: ${{ github.event.pull_request.number }} + + permissions: + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 ##### Fetch all history for all branches + + - name: Check PR for changes in protected folders ('src/*') + id: check_git_diff_for_protectected_folders + run: | + + + ##### Get all files modified by this PR + FILES=$(git diff --name-only origin/main HEAD) + + ##### Make sure that there are modified files + if [[ -z $FILES ]]; then + echo -e "\033[31mNo files found. This should not happen. Please check the code of the Github action. Aborting now.\033[0m" + echo "CONTINUE=false" >> "$GITHUB_ENV" + fi + + ##### Initialize empty variables + PROTECTED_CONTRACTS="" + + ##### Go through all modified file names/paths and identify contracts with path 'src/*' + while IFS= read -r FILE; do + if echo "$FILE" | grep -E '^src/.*\.sol$'; then + ##### Contract found + PROTECTED_CONTRACTS="${PROTECTED_CONTRACTS}${FILE}"$'\n' + fi + done <<< "$FILES" + + ##### Determine if audit is required + if [[ -z "$PROTECTED_CONTRACTS" ]]; then + echo -e "\033[32mNo protected contracts found in this PR.\033[0m" + echo "AUDIT_REQUIRED=false" >> "$GITHUB_ENV" + else + echo -e "\033[31mProtected contracts found in this PR.\033[0m" + echo "PROTECTED_CONTRACTS: $PROTECTED_CONTRACTS" + echo "AUDIT_REQUIRED=true" >> "$GITHUB_ENV" + echo "$AUDIT_REQUIRED" > audit_required.txt + echo -e "$PROTECTED_CONTRACTS" > protected_contracts.txt + fi + + - name: Assign, update, and verify labels based on check outcome + uses: actions/github-script@v7 + env: + AUDIT_REQUIRED: ${{ env.AUDIT_REQUIRED }} + with: + script: | + const { execSync } = require('child_process'); + // ANSI escape codes for colors (used for colored output in Git action console) + const colors = { + reset: "\033[0m", + red: "\033[31m", + green: "\033[32m", + }; + + // Fetch currently assigned labels from GitHub using GitHub CLI + let assignedLabels = []; + try { + // Fetch the labels directly from the pull request + const labelsOutput = execSync(`gh pr view ${{ github.event.pull_request.number }} --json labels --jq '.labels[].name'`).toString(); + + // Split the labels output into an array and trim each label + assignedLabels = labelsOutput.split('\n').map(label => label.trim()).filter(Boolean); + } catch (error) { + console.error(`${colors.red}Error fetching assigned labels: ${error.message}${colors.reset}`); + process.exit(1); + } + + // check if audit is required (determined by previous step) + const auditRequired = process.env.AUDIT_REQUIRED === 'true'; + + // determine which label should be assigned and which should be removed + const labelToAssign = auditRequired ? 'AuditRequired' : 'AuditNotRequired'; + const oppositeLabel = auditRequired ? 'AuditNotRequired' : 'AuditRequired'; + + console.log(`Currently assigned labels: ${JSON.stringify(assignedLabels)}`); + console.log(`Label '${labelToAssign}' has to be assigned to this PR`); + console.log(`Label '${oppositeLabel}' will be removed, if currently present`); + + // Assign the required label if not already present + if (!assignedLabels.includes(labelToAssign)) { + console.log(`Assigning label: ${labelToAssign}`); + execSync(`gh pr edit ${{ github.event.pull_request.number }} --add-label "${labelToAssign}"`, { stdio: 'inherit' }); + } else { + console.log(`${colors.green}Label "${labelToAssign}" is already assigned. No action needed.${colors.reset}`); + } + + // Remove the opposite label if it is present + if (assignedLabels.includes(oppositeLabel)) { + console.log(`Removing opposite label: ${oppositeLabel}`); + execSync(`gh pr edit ${{ github.event.pull_request.number }} --remove-label "${oppositeLabel}"`, { stdio: 'inherit' }); + } else { + console.log(`${colors.green}Opposite label "${oppositeLabel}" is not assigned. No action needed.${colors.reset}`); + } + + // fetch all currently assigned labels again + assignedLabels = [] + try { + // Fetch the labels directly from the pull request + const labelsOutput = execSync(`gh pr view ${{ github.event.pull_request.number }} --json labels --jq '.labels[].name'`).toString(); + + // Split the labels output into an array and trim each label + assignedLabels = labelsOutput.split('\n').map(label => label.trim()).filter(Boolean); + } catch (error) { + console.error(`${colors.red}Error fetching assigned labels: ${error.message}${colors.reset}`); + process.exit(1); + } + + // Verify that exactly one of the two labels is assigned + const requiredLabelCount = assignedLabels.filter(label => label === 'AuditRequired').length; + console.log(`requiredLabelCount: ${requiredLabelCount}`); + + const notRequiredLabelCount = assignedLabels.filter(label => label === 'AuditNotRequired').length; + console.log(`notRequiredLabelCount: ${notRequiredLabelCount}`); + const totalLabelsAssigned = requiredLabelCount + notRequiredLabelCount; + console.log(`totalLabelsAssigned: ${totalLabelsAssigned}`); + + if (totalLabelsAssigned !== 1) { + console.error(`${colors.red}Error: Exactly one of the two protected labels should be assigned but found ${totalLabelsAssigned} assigned labels.${colors.reset}`); + process.exit(1); + } else { + console.log(`${colors.green}Verified that exactly one label is assigned. Check passed :)${colors.reset}`); + } + + - name: Check Audit Log + continue-on-error: true + id: check-audit-log + if: env.AUDIT_REQUIRED == 'true' + run: | + + echo "This step will make sure that an audit is logged for each contract modified/added by this PR." + echo "It will also make sure that no information is missing in the audit log and that the information is meaningful." + + # load list of protected contracts + PROTECTED_CONTRACTS=$(cat protected_contracts.txt) + + # create temp files to store commit hashes and auditor handles + COMMIT_HASHES_FILE="commit_hashes.txt" + AUDITOR_GIT_HANDLES_FILE="auditor_handles.txt" + + ##### make sure that there are any protected contracts + if [[ -z $PROTECTED_CONTRACTS ]]; then + echo -e "\033[31mNo protected contracts found. This should not happen (action should stop earlier). Please check the code of the Github action. Aborting now.\033[0m" + echo "CONTINUE=false" >> "$GITHUB_ENV" + exit 1 + fi + + # iterate through all contracts + while IFS= read -r FILE; do + echo "-----------" + echo "now checking file $FILE" + ##### load contract version + VERSION=$(sed -nE 's/^\/\/\/ @custom:version ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' "$FILE") + + ##### make sure that contract version was extracted successfully + if [[ -z $VERSION ]]; then + echo -e "\033[31mCould not find version of contract $FILE. This should not happen. Please check the Github action code. Aborting now.\033[0m" + echo "CONTINUE=false" >> "$GITHUB_ENV" + exit 1 + fi + + ##### see if audit log contains an entry with those values + FILENAME=$(basename "$FILE" .sol) + + ##### Check if the contract and version exist in the JSON and get the audit IDs + AUDIT_IDS=$(jq -r --arg filename "$FILENAME" --arg version "$VERSION" \ + 'if .auditedContracts[$filename][$version] != null then .auditedContracts[$filename][$version][] else empty end' "$AUDIT_LOG_PATH") + + ##### Count the number of audits found in the log for this contract/version + if [[ -z "$AUDIT_IDS" ]]; then + AUDIT_COUNT=0 + else + AUDIT_COUNT=$(echo "$AUDIT_IDS" | wc -l) + fi + + ##### Ensure exactly one audit is logged; handle errors if not + if [[ $AUDIT_COUNT -ne 1 ]]; then + echo "CONTINUE=false" >> "$GITHUB_ENV" + + if [[ $AUDIT_COUNT -gt 1 ]]; then + echo -e "\033[31mError: Multiple audits found for contract $FILENAME in version $VERSION.\033[0m" + echo -e "\033[31mOnly one audit should be logged per contract version.\033[0m" + echo -e "\033[31mPlease fix the audit log and try again.\033[0m" + else + echo -e "\033[31mError: Could not find a logged audit for contract $FILENAME in version $VERSION.\033[0m" + echo -e "\033[31mThis check will not pass until the audit log contains a completed audit for this file.\033[0m" + fi + + exit 1 + fi + + + ##### Extract the single audit ID + AUDIT_ID=$(echo "$AUDIT_IDS" | head -n 1) + + ##### Extract audit entry details for the single audit ID + AUDIT_ENTRY=$(jq -r --arg audit_id "$AUDIT_ID" '.audits[$audit_id]' "$AUDIT_LOG_PATH") + + ##### Check if AUDIT_ENTRY is valid JSON + if [[ -z "$AUDIT_ENTRY" || "$AUDIT_ENTRY" == "null" ]]; then + echo -e "\033[31mError: The logged audit ID ($AUDIT_ID) for contract $FILE seems to be invalid.\033[0m" + echo "CONTINUE=false" >> "$GITHUB_ENV" + exit 1 + fi + + echo "File $FILE was audited in $AUDIT_ID" + echo "Now checking if all required information is logged for this audit..." + + ##### Extract log entry values into variables + AUDIT_COMPLETED_ON=$(echo "$AUDIT_ENTRY" | jq -r '.auditCompletedOn') + AUDITED_BY=$(echo "$AUDIT_ENTRY" | jq -r '.auditedBy') + AUDITOR_GIT_HANDLE=$(echo "$AUDIT_ENTRY" | jq -r '.auditorGitHandle') + AUDIT_REPORT_PATH=$(echo "$AUDIT_ENTRY" | jq -r '.auditReportPath') + AUDIT_COMMIT_HASH=$(echo "$AUDIT_ENTRY" | jq -r '.auditCommitHash') + + ##### make sure that audit log entry contains date + if [ -z "$AUDIT_COMPLETED_ON" ]; then + echo -e "\033[31mThe audit log entry for file $FILE contains an invalid or no 'auditCompletedOn' date.\033[0m" + echo -e "\033[31mThis github action cannot complete before the audit log is complete.\033[0m" + echo -e "\033[31mAborting now.\033[0m" + echo "CONTINUE=false" >> "$GITHUB_ENV" + exit 1 + else + echo "The audit log contains a date for $AUDIT_ID: $AUDIT_COMPLETED_ON" + fi + + ##### make sure that audit log entry contains auditor's (company) name + if [ -z "$AUDITED_BY" ]; then + echo -e "\033[31mThe audit log entry for file $FILE contains invalid or no 'auditedBy' information.\033[0m" + echo -e "\033[31mThis github action cannot complete before the audit log is complete.\033[0m" + echo -e "\033[31mAborting now.\033[0m" + echo "CONTINUE=false" >> "$GITHUB_ENV" + exit 1 + else + echo "The audit log contains the auditor's name for $AUDIT_ID: $AUDITED_BY" + fi + + ##### make sure that audit log entry contains auditor's git handle + if [ -z "$AUDITOR_GIT_HANDLE" ]; then + echo -e "\033[31mThe audit log entry for file $FILE contains invalid or no 'auditorGitHandle' information.\033[0m" + echo -e "\033[31mThis github action cannot complete before the audit log is complete.\033[0m" + echo -e "\033[31mAborting now.\033[0m" + echo "CONTINUE=false" >> "$GITHUB_ENV" + exit 1 + else + echo "The audit log contains the auditor's github handle for $AUDIT_ID: $AUDITOR_GIT_HANDLE" + fi + + ##### make sure that a file exists at the audit report path + if [ ! -f "$AUDIT_REPORT_PATH" ]; then + echo -e "\033[31mCould not find an audit report in path $AUDIT_REPORT_PATH for contract "$FILENAME".\033[0m" + echo -e "\033[31mThis github action cannot complete before the audit report is uploaded to 'audit/reports/'.\033[0m" + echo -e "\033[31mAborting now.\033[0m" + echo "CONTINUE=false" >> "$GITHUB_ENV" + exit 1 + else + echo "The audit report for $AUDIT_ID was found in path $AUDIT_REPORT_PATH" + fi + + ##### make sure that audit log entry contains audit commit hash + if [ -z "$AUDIT_COMMIT_HASH" ]; then + echo -e "\033[31mThe audit log entry for file $FILE contains invalid or no 'auditCommitHash' information.\033[0m" + echo -e "\033[31mThis github action cannot complete before the audit log is complete.\033[0m" + echo -e "\033[31mAborting now.\033[0m" + echo "CONTINUE=false" >> "$GITHUB_ENV" + exit 1 + else + echo "The audit log contains the commit hash that was audited in $AUDIT_ID: $AUDIT_COMMIT_HASH" + fi + echo -e "\033[32mThe audit log contains all required information for contract $FILE\033[0m" + + echo "now checking if audit commit hash $AUDIT_COMMIT_HASH is associated with PR $PR_NUMBER" + ##### Fetch the list of commits associated with the PR + COMMIT_LIST=$(gh pr view "$PR_NUMBER" --json commits --jq '.commits[].oid') + + ##### Check if the target commit is in the list + if echo "$COMMIT_LIST" | grep -q "$TARGET_COMMIT"; then + echo -e "\033[32mCommit $AUDIT_COMMIT_HASH is associated with PR #$PR_NUMBER.\033[0m" + else + echo -e "\033[31mCommit $AUDIT_COMMIT_HASH is NOT associated with PR #$PR_NUMBER.\033[0m" + echo "CONTINUE=false" >> "$GITHUB_ENV" + exit 1 + fi + + + ##### Check if the auditor git handle exists on github + echo "now checking if the auditor git handle ($AUDITOR_GIT_HANDLE) actually exists" + if gh api users/$AUDITOR_GIT_HANDLE > /dev/null 2>&1; then + echo -e "\033[32mA user with handle '$AUDITOR_GIT_HANDLE' exists on GitHub.\033[0m" + else + echo -e "\033[31mA user with handle '$AUDITOR_GIT_HANDLE' does not exist on GitHub.\033[0m" + echo -e "\033[31mPlease fix the audit log before continuing.\033[0m" + echo -e "\033[31mCheck failed.\033[0m" + echo "CONTINUE=false" >> "$GITHUB_ENV" + exit 1 + fi + + # ##### ----------------------------------------------------------------------------- + # ##### DISABLED FOR NOW (NEED TO CHECK IF THIS IS COMPATIBLE WITH OUR FLOW) + # ##### Fetch PR reviews using the GitHub API via gh cli + # echo "now checking if the auditor ($AUDITOR_GIT_HANDLE) approved this PR ($PR_NUMBER)" + # REVIEWS=$(gh api repos/lifinance/contracts/pulls/$PR_NUMBER/reviews --jq '.[] | select(.state == "APPROVED") | @json') + + # ##### Check if the output is empty or not valid JSON + # if [[ -z "$REVIEWS" ]]; then + # echo "ERROR: No reviews found or failed to fetch reviews for PR #$PR_NUMBER" + # exit 1 + # fi + # ##### Flag to track if the review by the specified person is found + # FOUND_REVIEW=false + + # ##### Check if the desired reviewer is present among the reviews + # echo "$REVIEWS" | jq -c '.' | while read -r REVIEW; do + # AUTHOR=$(echo "$REVIEW" | jq -r '.user.login // empty') + # STATE=$(echo "$REVIEW" | jq -r '.state // empty') + + # echo "found review by $AUTHOR with state $STATE" + + + # ##### Check if the reviewer is the person we're looking for + # if [ "$AUTHOR" == "$REVIEWER" ]; then + # echo "Approving review found by $REVIEWER" + # FOUND_REVIEW=true + # exit 0 + # fi + # done + + # ##### If no matching review was found, exit with an error + # if [ "$FOUND_REVIEW" == true ]; then + # echo -e "\033[32mPR $PR_NUMBER has an approving review by $AUDITOR_GIT_HANDLE\033[0m" + # echo -e "\033[32mCheck passed\033[0m" + # exit 0 + # else + # echo -e "\033[31mERROR: No review found by git user '$AUDITOR_GIT_HANDLE' (= the auditor)\033[0m" + # echo -e "\033[31mCheck failed\033[0m" + # echo "CONTINUE=false" >> "$GITHUB_ENV" + # exit 1 + # fi + # ##### ----------------------------------------------------------------------------- + + + done <<< "$PROTECTED_CONTRACTS" + + echo -e "\033[32mSuccessfully verified that all contracts in this PRs are audited.\033[0m" + echo -e "\033[32mCheck passed.\033[0m" + echo "CONTINUE=true" >> "$GITHUB_ENV" + echo "Assigning label 'AuditCompleted' next" + + - name: Assign label "AuditCompleted" if all checks passed + if: ${{ env.AUDIT_REQUIRED == 'true' && env.CONTINUE == 'true' }} + uses: actions-ecosystem/action-add-labels@v1 + id: assign_label + with: + github_token: ${{ secrets.GIT_ACTIONS_BOT_PAT_CLASSIC }} # we use the token of the lifi-action-bot so the label protection check will pass + labels: 'AuditCompleted' + number: ${{ env.PR_NUMBER }} + + - name: Remove label "AuditCompleted" in case check was not successful but label was assigned in earlier checks + continue-on-error: true # This ensures the step will execute even if the job has a failed status. + uses: actions-ecosystem/action-remove-labels@v1 + if: ${{ env.CONTINUE == 'false' || (env.CONTINUE == 'true' && env.AUDIT_REQUIRED == 'false')}} + with: + github_token: ${{ secrets.GIT_ACTIONS_BOT_PAT_CLASSIC }} # we use the token of the lifi-action-bot so the label protection check will pass + labels: 'AuditCompleted' + number: ${{ env.PR_NUMBER }} + + - name: Fail the git action if any critical step failed + if: env.CONTINUE == 'false' # This step runs only if a failure was recorded + run: | + + echo -e "\033[31mError: One or more critical steps failed. Failing the job.\033[0m" + exit 1