#!/usr/bin/env bash
	
# Static security analysis script
#
# Installation:
# 1. install git for file change tracking
# 2. install golang. go must be atleast 1.18 for script to work
# 3. install scans (gosec, govulncheck). This can do done using `./static_analyis.sh -I` or `make Analyze-Install`
# 4. Use the -h options for help with this tool and the -d option for default scan location
# 5. This script should be ran through the makefile using `make analyze` to use the recommended flags.
# 6. Using -o=dir or -o=file will create formated markdown files for easier debugging
# 7. This script is designed to scan packages not files. To scan ./agent/agent/agent.go, pass in ./agent/agent/
# 8. The order of flags matters! For example, using ./static_analysis.sh -I -s=gosec will install all 
#    dependencies but then only scan with gosec. While ./static_analysis.sh -s=gosec -I will tell the intaller to only install gosec
#   
# Example commands:
# 1) ./static_analysis.sh -d -o=file -n="results.md" -s="gosec govulncheck" 
# -> [-d] Uses default package locations
# -> [-o=file] Output to file
# -> [-n="results.md"] Renames file to results.md
# -> [-s="gosec govulncheck"] Use gosec and govulncheck scan
#
# 2) ./static_analysis.sh -o=dir -f -t ./agent/agent/agent/ ./core
# -> [-o=dir] Output to a directory with default name
# -> [-f] enables fast fail. Exits on first scan that fails
# -> [-t] scans test files to
# -> [./agent/agent/agent/] location 1
# -> [./core] location 2

# Configurations Information
#======================================================================================================

# Packages that will be scanned when the -d (--default) flag is set. 
# This list of directories where choosen as packages that are maintained by SSM agent team
DEFAULT_PACKAGES="./agent ./core ./common ./internal"

# Names of each scanning library that is avalible to use. Code must pass govulncheck and gosec scans
# Note that the index must match for NAMES, FLAGS, TEST_FLAGS, SCANNER_INSTALLATION_URL
NAMES=(
  "govulncheck"
  "gosec"
  # "staticcheck"    
  # "golangci-lint"
)

# Default flags used to run command. Index correspond to the scan above
FLAGS=(
  "" 
  "-quiet -severity high -confidence high" 
  # "" 
  # "run"
)

# Additional flags that are enabled when -t (--test) flag is set
TEST_FLAGS=(
  "-test"
  "-tests"
  # "-test"
  # ""
) 

# Installation URLs. Go must be > 1.16 to use go install command
# TODO: Update gosec install command to "go install github.com/securego/gosec/v2/cmd/gosec@latest"
SCANNER_INSTALLATION_URL=(
  "go install golang.org/x/vuln/cmd/govulncheck@latest"
  "curl -sfL https://raw.githubusercontent.com/securego/gosec/master/install.sh | sh -s -- -b $(go env GOPATH)/bin"
  # "go install honnef.co/go/tools/cmd/staticcheck@v0.3.1"
  # "go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"
)
#=======================================================================================================

# Update path to include installed binaries
PATH=${PATH}:$(go env GOPATH)/bin

# Color and text constants for shell. Checks that we are not inside a dumb terminal which has no color features
if [[ ${TERM} != "dumb" ]]; then
  RED=$(tput setaf 1)
  GREEN=$(tput setaf 2)
  YELLOW=$(tput setaf 3)
  BLUE=$(tput setaf 4)
  MAGENTA=$(tput setaf 5)
  CYAN=$(tput setaf 6)
  RESET_COLOR=$(tput init)
  BOLD=$(tput bold)
  UNDERLINE=$(tput smul)
  NORMAL=$(tput sgr0)
fi

# Help Message
help="
${YELLOW}This script uses multiple static security analysis libraries to scan and find known CVEs and vulnerabilities within Golang packages${RESET_COLOR}\n
   ${BOLD}${UNDERLINE}Usage${NORMAL}:
\t$(basename $0) [options] [files...]
   ${BOLD}${UNDERLINE}Options${NORMAL}:
\t-s  --scanner=\"arg...\"    List of security scanners that will be used currently there ${SCANNERS} are available
\t    --[scanner]=\"flags\"   Pass in additional command flags to scanner
\t-d  --default             Runs scans on specified default locations
\t-f  --fail                Prevents script from exiting after first failure
\t-r  --rel=<path>          Set the path all commands are run relative to
\t-i  --install             Installs latest scanners automatically if missing just before a scan start
\t-I                        Installs scanners dependencies before all scanners        
\t-q  --quiet               Disables additional prints
\t-c  --color               Disables color output 
\t-t  --tests               Enable scanning on test code
\t-o  --out=<file|dir>      Set location for debugging output. Defaults to console
\t-n  --name=\"name\"         Names of the output file/directory
\t${GREEN}-h  --help                Help information${RESET_COLOR}
\t-g  --go=\"version\"        Go version override
   ${BOLD}${UNDERLINE}Arguments${NORMAL}:
\t[files...]                Defaults to ./... which recursively scans all subpackages of the project 
"

# Toggleable echo function. If -q (--quiet) is set we do not echo logging messages
print() {
  [[ ${option_q} == true ]] || echo -e $@
}

# Returns the index of scanner from NAMES. if output is > array length, element is not contained inside the list
# $1: element we are searching for inside of $NAMES[@]
# Returns: index within $NAMES if $1 is found otherwise len($NAMES)
indexOf() {    
  for i in ${!NAMES[@]}; do
    if [ ${NAMES[$i]} == $1 ]; then
        return $i
    fi
  done
  return ${#NAMES[@]}
}

# Checks installation of scanners and install based on provided options:
# $1: command we are running
# $2: installation command
checkInstallation() { 
  if  [[ -x $(command -v $1) ]]; then
    print "${GREEN}Found ${CYAN}$1${RESET_COLOR} executable"
    return 0
  elif [[ $3 == true ]]; then
    print "${YELLOW}Installing ${CYAN}$1${RESET_COLOR} using \"$2\""
    eval $2 
    if  [[ $(command -v $1) ]]; then
      print "${GREEN}Installation Successfull!${RESET_COLOR} continuing"
      return 0
    fi
    print "${BOLD}${RED}Installation Failed!${NORMAL}${RESET_COLOR} There may be something wrong with installation link"
    exit 1
  else
    print "${BOLD}${RED}Error!${NORMAL}${RESET_COLOR} ${CYAN}$1${RESET_COLOR} executable not found. Please install or use ${YELLOW}${name} -i${RESET_COLOR} flag"
    exit 1
  fi
}

# Default option values
option_s=${NAMES[@]}  # List of scanners that will be used this run (defaults to all of them)
option_d=false        # Use default scan locations
option_f=true         # Prevents program from exiting after a scanner finds error
option_i=false        # Installs recommended versions of scann
option_q=false        # Runs script in quiet mode
option_t=false        # Also scan test files
option_o=""           # Sets the debug log output location. Default to the terminal

name=$(basename $0)
filename=${name%???}_results

# Args parsing for arguments in the form -x="a b c ..."
# $1 is the prefix: (x)
# $2 is the suffix: "a b c ..."
special_args() {
  case $1 in 
    s|scanner)
      option_s=()
      for scan in $2; do
        indexOf ${scan}
        if [[ $? == ${#NAMES[@]} ]]; then
          echo -e "${RED}${scan}${RESET_COLOR} is not a valid option for -s. Select from ${GREEN}[${NAMES[@]}]${RESET_COLOR}"
          exit 1
        fi
        option_s+=(${scan})
      done
      ;;
    r|rel)
      cd $2
      if [[ $? != 0 ]]; then
        echo -e "Unable to change directory to ${RED}\"$2\"${RESET_COLOR}!"
        exit 1
      fi
      ;;
    o|out)
      if [[ $2 != "file" && $2 != "dir" ]]; then
        echo -e "${RED}$2${RESET_COLOR} is not a valid option for -o. Must be \"file\" or \"dir\""
        exit 1
      fi
      option_o="$2"
      ;;
    n|name)
      filename="$2"
      ;;
    g|go)
      go install golang.org/dl/$2@latest
      $2 download
      export PATH=$($2 env GOROOT)/bin:$PATH
      ;;
    *)
      indexOf ${1}
      index=$?
      if [[ ${index} == ${#NAMES[@]} ]]; then
        echo -e "${RED}${1}${RESET_COLOR} is not a valid option"
        exit 1
      fi
      FLAGS[${index}]="$2 ${FLAGS[${index}]}"
      ;;
  esac
}

# Start of script logic here 
while (( $# )); do
  case $1 in
    -d|--default)
      option_d=true
      ;;
    -f|--fail)
      option_f=false
      ;;
    -i|--install)
      option_i=true
      ;;
    -I)
      print "${GREEN}Installing dependencies"
      for scanner in ${option_s[@]}; do
        indexOf ${scanner}
        index=$?
        checkInstallation "${scanner}" "${SCANNER_INSTALLATION_URL[${index}]}" "true"
      done
      print "${GREEN}Installation complete!${RESET_COLOR} Exiting"
      ;;
    -q|--quiet)
      option_q=true
      ;;
    -t|--tests)
      option_t=true
      ;;
    -c|--color)
        unset RED
        unset GREEN 
        unset YELLOW
        unset BLUE
        unset MAGENTA
        unset CYAN
        unset RESET_COLOR
        unset BOLD
        unset UNDERLINE
        unset NORMAL
      ;;
    -h|--help)
      echo -e "$help"
      exit 0
      ;;
    -[a-zA-Z0-9]=*)
      suffix=${1#*=}
      prefix=${1%=*}; prefix=${prefix:1}
      special_args "$prefix" "$suffix"
      ;;
    --[a-zA-Z0-9\-]*=*)
      suffix=${1#*=}
      prefix=${1%=*}; prefix=${prefix:2}
      special_args "$prefix" "$suffix"
      ;;
    -*)
      echo -e "${RED}${1}${RESET_COLOR} is not a valid option"
      exit 1
      ;;
    *)
      break
      ;;
  esac
  shift
done

# Append test flags if -t (--test) is set
if [[ ${option_t} == true ]]; then
  for index in ${!FLAGS[@]}; do
    FLAGS[${index}]="${FLAGS[${index}]} ${TEST_FLAGS[${index}]}"
  done
fi

# Run the scans depending on options provide
runScan() {
  local out=0
  # Check if output is terminal or file and create logs accordinly
  if [[ ${option_o} == "" ]]; then
    for package_dir in ${scan_packages}; do
      print "${YELLOW}Scanning${RESET_COLOR}: ${package_dir}/... with command ${GREEN}\"$1 $2 ${package_dir}/...\"${RESET_COLOR}"
      command $1 $2 ./${package_dir}/...
      result=$?
      if [[ ${result} != "0" ]]; then 
        print "${RED}> Fail${RESET_COLOR} ${package_dir} failed $1 scan${RESET_COLOR}"
      else
        print "${GREEN}> Passed${RESET_COLOR} ${package_dir} passed $1 scan${RESET_COLOR}"
      fi
      out=$(( $out + $result )) 
    done
  else
    
    if [[ ${option_o} == "dir" ]]; then
      path_append="/$1.md"
    fi
    tablebuilder="|Package|Result|\n|---|---|\n"
    for package_dir in ${scan_packages}; do
      print "${YELLOW}Scanning${RESET_COLOR}: ${package_dir}/... with command ${GREEN}\"$1 $2 ${package_dir}/...\"${RESET_COLOR}"
      
      echo -e "## Scanning \`${package_dir}\`\n\`\`\`" >> ${filename}${path_append}

      command $1 $2 ./${package_dir}/... >> ${filename}${path_append}
      result=$?
      
      if [[ ${result} == "0" ]]; then
        echo -e "No vulnerabilities found" >> ${filename}${path_append}
      fi

      echo -e "\`\`\`" >> ${filename}${path_append}

      if [[ ${result} != "0" ]]; then 
        print "${RED}> Fail${RESET_COLOR} ${package_dir} failed $1 scan${RESET_COLOR}"
        tablebuilder=${tablebuilder}"|${package_dir}|Failed|\n"
      else
        print "${GREEN}> Passed${RESET_COLOR} ${package_dir} passed $1 scan${RESET_COLOR}"
        tablebuilder=${tablebuilder}"|${package_dir}|Passed|\n"
      fi

      out=$(( $out + $result )) 
    done
    echo -e ${tablebuilder} >> ${filename}${path_append}
  fi
  return ${out}
}

# Find packages
if [[ ${option_d} == true ]]; then
  scan_packages=${DEFAULT_PACKAGES}
elif [[  $# == 0 ]]; then

  # The following line of code performs these steps
  # 1. "git status --porcelain" gets changed files using porcelain as its format 
  # 2. "awk {print $2}" gets the 2nd string from each line
  # 3. "grep \.go" filters out files that do not end in .go
  # 4. "xargs -r 'dirname" gets the directory name associated with each file
  # 5. "sort" sorts output
  # 6. "uniq" Filter out duplicate file locations
  scan_packages=$(git status --porcelain=v1 | awk '{print $2}' | grep '\.go$' | xargs -r 'dirname' | sort | uniq)

  if [[ -z ${scan_packages} ]]; then
    print "${GREEN}No package changes found since last commit${RESET_COLOR}. Specify package paths manually or use ${YELLOW}${name} -d${RESET_COLOR} for defaults locations"
    exit 0
  fi
fi 
scan_packages=" $@ ${scan_packages[@]} "

# Creating logfiles if necessary
if [[ ${option_o} == "file" ]]; then
  print "${MAGENTA}Creating${RESET_COLOR} file ${filename}"
  echo -e "# Vulnerability Report\n>Date: $(date)\n>\n>Locations: [${option_s[@]}] \n>\n>Version: $(go version)" > ${filename}
elif [[ ${option_o} == "dir" ]]; then
  print "${MAGENTA}Creating${RESET_COLOR} directory ./${filename}"
  mkdir -p ${filename}
  for scanner in ${option_s[@]}; do
    echo -e "# ${scanner} report\n>Date: $(date)\n>\n>Locations: [${option_s[@]}] \n>\n>Version: $(go version)" > ./${filename}/${scanner}.md
  done
fi

# Start scanner code
print "${BOLD}${GREEN}Running analysis ${NORMAL}${RESET_COLOR}script using $(go version)"
exitcode=0
for scanner in ${option_s[@]}; do
  indexOf ${scanner} 
  index=$?
  print "${BOLD}${GREEN}Starting ${NORMAL}${CYAN}${scanner}${RESET_COLOR} scanner"
  checkInstallation "${scanner}" "${SCANNER_INSTALLATION_URL[${index}]}" ${option_i}
  if [[ $? == 0 ]]; then
    runScan "${scanner}" "${FLAGS[${index}]}"
    result=$?
    print "${GREEN}Completed ${CYAN}${scanner}${RESET_COLOR} scan"
  
    if [[ ${result} != 0 ]]; then
      if [[ ${option_f} == false ]]; then
        print "${RED}Exiting! ${CYAN}${scanner}${RESET_COLOR} found errors"
        exit 1
      else
        print "${RED}Errors${RESET_COLOR} found during ${CYAN}${scanner}${RESET_COLOR} scan"
      fi
    fi

    exitcode=$(( ${result} + ${exitcode} ))

  elif [[ ${option_f} == false ]]; then
    exit 1
  fi
done

# Log messages
print "${GREEN}Completed!${RESET_COLOR} all scans on $(date)"
if [[ ${exitcode} == 0 ]]; then
  echo -e "${GREEN}Success!${RESET_COLOR} no errors found during scanning using $(go version)"
else
  if [[ $option_o == "file" ]]; then
    append="Results are located at ${MAGENTA}${filename}${RESET_COLOR}"
  elif [[ $option_o == "dir" ]]; then
    append="Results are located at ${MAGENTA}./${filename}/${RESET_COLOR}"
  fi
  echo -e "${RED}Errors${RESET_COLOR} where found when scanning using $(go version) ${GREEN}${scan_packages}${RESET_COLOR}. ${append}"
fi

exit ${exitcode}