#!/bin/bash
# SPDX-License-Identifier: GPL-2.0
#
# Copyright 2024 Google LLC
#
# Author: Lee Jones <lee@kernel.org>
#
# Usage
#   cve_review [ v6.7.1..v6.7.2 | filename ]
#
# * Highlights key words often used to make sane judgements
# * Able to accept a Stable Git range or a file in `git log --oneline` format
# * Provides an on-going progress report in the form "x of y (z%)"
# * Presents commit on a clean terminal without clipping scroll-back
# * Tracks progress of reviews and will skip commits already processed
#   - Progress is tracked in <VULNS>/tmp/cve-review (which Git ignores)
# * Clips commits to a little less than the size of your terminal
#   - This ensures that commit message is always visible
#   - The remainder of the commit message can be seen by pressing 'M'
#
# Requires:
#  * Remember to change the user-specific variables a few lines down
#  * Expected to be executed from inside a kernel Git repository

# set -x                        # Uncomment to enable debugging
set -e                          # Exit on any error

# -------   ACTION REQUIRED   -------
# Change these to suit your own setup
NAME=lee
STABLEREMOTE=stable     # Whatever you called your Stable remote

if [[ "$(whoami)" == "gregkh" ]]; then
    NAME=greg
fi

if [[ "$(whoami)" == "sasha" ]]; then
    NAME=sasha
fi

function usage()
{
    echo "Usage: $(basename ${0}) {rangebottom..rangetop}"
    exit 1
}

print_red()
{
    echo -e "\e[01;31m$@\e[0m"
}

print_blue()
{
    echo -e "\e[01;34m$@\e[0m"
}

shopt -s extglob
while [ $# -gt 0 ]; do
    case $1 in
        *+([0-9])\.\.*)
            RANGE=${1}
            ;;
        *+([0-9])\.*+([0-9])\.\.)
            RANGE=${1}
            ;;
        --annotate|-a)
            ANNOTATE=true
            ;;
        --skip-reviewed|-s)
            SKIPREVIEWED=true
            ;;
        *)
            if [ -s "${1}" ]; then
                FILE=${1};
                shift
                continue
            fi

            if git cat-file -t ${1} > /dev/null 2>&1; then
                SHAS+=(${1})
                shift
                continue
            fi

            print_red "Unrecognised argument: ${1}"
            usage
            ;;
    esac
    shift
done
shopt -u extglob

if [[ "${ANNOTATE}" == "true" && "${FILE}" == "" ]]; then
    print_red "When annotating, a file containing commits in --oneline format must be provided"
    exit 1
fi

if [ "${RANGE}" == "" ]; then
    if [ "${ANNOTATE}" == "true" ]; then
        print_red "A version must be provided e.g. v6.7.2"
        exit 1
    elif [[ "${FILE}" == "" && "${SHAS[@]}" == "" ]]; then
        print_red "A range must be provided e.g. v6.7.2..v6.7.3"
        exit 1
    fi
fi

if [ ! -e .git ] || [ ! -f MAINTAINERS ]; then
    print_red "Not in a kernel directory"
    exit 1
fi

print_blue "Fetching from ${STABLEREMOTE}"
git fetch ${STABLEREMOTE} || print_blue "Unable to fetch - continuing anyway\n"

TAG=${RANGE#*..}
if [ -s "${FILE}" ]; then

    if [ "${ANNOTATE}" == "true" ]; then
        TAG=${TAG}-annotated
    else
        TAG="$(basename ${FILE})-fromfile"
    fi

    while read line; do
        # Skip annotations (TODO: Collect these and present them during review)
        if echo ${line} | grep -Eq "^\s*-"; then
            continue
        fi

        SHAS+=($(echo ${line} | grep -oE "^\s*[a-f0-9]{7,}"))
    done < ${FILE}
elif [ "${RANGE}" != "" ]; then
    H=$(git log --reverse --format=%h ${RANGE})
    for h in ${H}; do
        SHAS+=($h)
    done
else
    TAG=${SHAS[0]}
fi

NOSHAS=$(echo ${SHAS[@]} | wc -w)
if [ ${#SHAS[@]} -le 0 ]; then
    print_red "No commits to review"
    exit 0
fi

SCRIPTDIR=$(dirname ${0})
FINALDIR=${SCRIPTDIR}/../cve/review/proposed
PUBLISHEDDIR=${SCRIPTDIR}/../cve/published
WORKDIR=${SCRIPTDIR}/../tmp/cve-review
PROCESSEDDIR=${WORKDIR}/processed
RESULTSDIR=${WORKDIR}/results
PROCESSEDFILE=${PROCESSEDDIR}/${TAG}
CVEMEFILE=${TAG}-${NAME}
CVEME=${RESULTSDIR}/${CVEMEFILE}
UPDATEFINALDIR=""

mkdir -p ${PROCESSEDDIR} ${RESULTSDIR}

print_blue "Reviewing ${NOSHAS} commits"
count=0

for h in ${SHAS[@]}; do
    clipcommitto=$(($(tput lines) - 9))
    oneline=$(git --no-pager log ${h} -n1 --format="%h %s")
    subject=$(echo ${oneline} | cut -d' ' -f 2-)
    count=$((count + 1))
    percentage=$(echo "scale=4; (${count}/${NOSHAS})*100" | bc | awk '{printf "%.2f\n", $0}')
    alreadyreviewed=""

    if grep -q -s -F "${oneline}" ${PROCESSEDFILE}; then
        print_blue "Skipping already processed commit: ${oneline}"
        continue
    fi

    # Shift the screen up without loosing scroll-back
    for l in $(seq 1 $(tput lines)); do
        printf '\n';
    done
    clear

    print_blue "Processing ${TAG} fix: ${count} of ${NOSHAS} (%${percentage})"

    MATCHES="\
call[-\s_]*trace|\
dead[-\s_]*lock|lock[-\s_]*up|\
nul[l]*[-\s_]*p[a-z]*[-\s_]*deref[a-z]*|nul[l]*[-\s_]*p[a-z]*|null|deref[a-z]*|\
div[-\s_a-z]*by[-\s_]*zero|divi[-\s_a-z]*by[-\s_]*0|\
double[-\s_]*free|\
kernel[-\s_]*bug|\
buffer[-\s_]*overflow|over[-\s_]*run|over[-\s_]*flow|\
out[-\s_]*of[-\s_]*bound[s]*|bound[s]*|\
[-\s_]race|[-\s_]racing|\
use[-\s_]*after[-\s_]*free|use[-\s_]*after|after[-\s_]*free|\
circular|\
corrupt[a-z]*|\
crash[a-z]*|\
denial|\
dos|\
exploit|\
[-\s_]fault|\
[-\s_]hang[a-z]*|hung|\
info[-\s_a-z].*leak|leak|\
malicious[a-z]*|\
kernel[-\s_]*memory|memory|\
oob|\
oops|\
panic|\
refcount|\
system|\
syzkaller|\
syzbot|\
uaf|\
underflow|\
uninitial[a-z]*|\
vuln[a-z]*|\
BUG|\
KSPP|\
WARN[A-Z]*\
"
    mainlinesha=$(git --no-pager log -n1 ${h} | grep -i upstream ${f} | grep -oE "[a-f0-9]{40,}") || true
    commitmsgfile=$(mktemp /tmp/cve-review-XXXXX)
    commitfile=$(mktemp /tmp/cve-review-XXXXX)

    git --no-pager log --stat --color -n1 ${h} > ${commitmsgfile}

    cat ${commitmsgfile} | grep -C99999 --color=always -Pi "${MATCHES}" > ${commitfile} || true

    if [ ! -s ${commitfile} ]; then
         cat ${commitmsgfile} > ${commitfile}
    fi
    rm -f ${commitmsgfile}

    if [ "${mainlinesha}" != "" ]; then
        pubpath=$(grep -rlF "${subject}" ${PUBLISHEDDIR} | grep json | xargs grep -l ${mainlinesha}) || true
    fi

    if [ "${pubpath}" != "" ]; then
        pubfile=$(echo $(basename ${pubpath}) | sed 's/.json//')
        print_red "\nCVE already published as ${pubfile} -- skipping"

        sleep 1
        echo "${oneline}" >> ${PROCESSEDFILE}

        echo "$(git --no-pager log ${mainlinesha} -n1 --format="%h %s") [auto: cve already created]" >> ${CVEME}
        continue
    fi

    if grep -rqF "${subject}" ${PROCESSEDDIR}; then
        filename=$(basename $(grep -lrF "${subject}" ${PROCESSEDDIR} | head -n1))
        sha=$(grep -rhF "${subject}" ${PROCESSEDDIR} | cut -d' ' -f1)

        print_red "\nPotentially already reviewed in\n  "

        echo -n "  ${filename}: "
        echo "$(grep -hF "${subject}" ${PROCESSEDDIR}/*)"

        if [ "${SKIPREVIEWED}" == "true" ]; then
            newpatchid=$(git show ${h} | git patch-id | cut -d' ' -f1)
            oldpatchid=$(git show ${sha} | git patch-id | cut -d' ' -f1)

            if [ "${newpatchid}" == "${oldpatchid}" ]; then
                print_blue "\nConfirmed as already reviewed - SKIPPING\n"
                sleep 1
                rm -f ${commitfile}
                echo "${oneline}" >> ${PROCESSEDFILE}
                continue
            else
                print_blue "\nPatch ID doesn't match - please review for similarity manually"
            fi
        fi

        clipcommitto=$((clipcommitto - 5))
    fi
    echo

    hits=$(grep -F "${subject}" ${FINALDIR}/* | wc -l)
    if [ ${hits} -gt 0 ]; then
        print_red "Positively voted for in:\n"
        grep -Fl "${subject}" ${FINALDIR}/* | sed 's!'"${FINALDIR}/"'!  !'
        clipcommitto=$((clipcommitto- ${hits} - 3))
        echo
    fi

    print_blue "Summary:\n"

    git --no-pager log -p --format="" --color=always -n1 ${h} >> ${commitfile}
    commitlen=$(cat ${commitfile} | wc -l)

    if [ "${NAME}" == "greg" ]; then
        bat ${commitfile}
    elif [ ${commitlen} -gt ${clipcommitto} ]; then
        head -n ${clipcommitto} ${commitfile}
        print_blue "\nCommit has been clipped, press M to see the remainder"
    else
        cat ${commitfile}
        print_blue "\nCommit not clipped"
    fi

    if [ "${ANNOTATE}" != "true" ]; then
        print_blue "\nShould this commit be assigned a CVE <y/N/q>?"
    else
        print_blue "\nPlease annotate <description/q>"
    fi
    echo -n "> "
    read CHOICE

    if [[ "${CHOICE}" == "m" || "${CHOICE}" == "M" ]]; then
        echo
        tail -n $((commitlen - ${clipcommitto})) ${commitfile}

        if [ "${ANNOTATE}" != "true" ]; then
            print_blue "\nShould this commit be assigned a CVE <y/N/q>?"
        else
            print_blue "\nPlease annotate <description/q>:"
        fi
        echo -n "> "
        read CHOICE
    fi
    rm -f ${commitfile}

    if [[ "${CHOICE}" == "q" || "${CHOICE}" == "Q" ]]; then
        print_blue "\nExiting"
        exit 0
    fi

    if [[ "${CHOICE}" == "y" || "${CHOICE}" == "Y" || "${CHOICE}" == "]" || "${ANNOTATE}" == "true" ]]; then
        # If the commit does not contain a Mainline SHA, we'll assume it *is* a Mainline SHA
        if [ "${mainlinesha}" == "" ]; then
            mainlinesha="${h}"
        fi

        git --no-pager log ${mainlinesha} -n1 --format="%h %s" >> ${CVEME}

        if [ "${ANNOTATE}" == "true" ]; then
            echo "- [${NAME}] ${CHOICE}" >> ${CVEME}
        fi

        UPDATEFINALDIR=true
    fi

    echo "${oneline}" >> ${PROCESSEDFILE}
done

if [ "${UPDATEFINALDIR}" == "true" ]; then
    cat ${CVEME} >> ${FINALDIR}/${CVEMEFILE}
fi
