diff --git a/.github/workflows/bandit-scan.yml b/.github/workflows/bandit-scan.yml new file mode 100644 index 0000000..b4ee055 --- /dev/null +++ b/.github/workflows/bandit-scan.yml @@ -0,0 +1,20 @@ +name: Bandit Code Scan + +on: + pull_request: + branches: [ main ] + +permissions: + pull-requests: write +jobs: + bandit-action: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + - name: Run Bandit Scan + uses: ./ + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + path: "." + recursive: "true" diff --git a/Dockerfile b/Dockerfile index 5c0d674..8c6d003 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,19 @@ -FROM python:3.8-slim +FROM ghcr.io/pycqa/bandit/bandit:latest -LABEL "maintainer"="PyCQA " -LABEL "repository"="https://github.com/PyCQA/bandit-action" -LABEL "homepage"="https://github.com/PyCQA/bandit-action" +ENV GITHUB_TOKEN="" +ENV GITHUB_REPOSITORY="" -RUN pip install bandit +# Install additional dependencies if necessary +RUN apk add --no-cache git bash python3 py3-pip && \ + pip install PyGithub + +# Copy the entrypoint script +COPY entrypoint.sh /entrypoint.sh + +# Make the entrypoint script executable +RUN chmod +x /entrypoint.sh + +# Assuming the Dockerfile is located at the root of the repository +COPY post_comment.py /post_comment.py -ADD entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index 1675709..c39efec 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,115 @@ -# bandit-action -GitHub Action for Bandit +# GitHub Action for Bandit + +This is the official GitHub Action for running +[Bandit](https://bandit.readthedocs.io/en/latest/), developed by the maintainers +of Bandit. It is designed to be configurable and easy to use. + +## Features + +- :gear: Fully configurable with input parameters and support for config files. +- :speech_balloon: Posts scan results as a comment on pull requests. + +## Inputs + +| Name | Description | Default | +|----------------------|-------------------------------------------------------------|---------| +| `recursive` | Find and process files in subdirectories. | `false` | +| `aggregate` | Aggregate output by vulnerability or by filename. | `vuln` | +| `context_lines` | Maximum number of code lines to output for each issue. | | +| `config_file` | Optional config file to use for selecting plugins. | | +| `profile` | Profile to use, defaults to executing all tests. | | +| `tests` | Comma-separated list of test IDs to run. | | +| `skips` | Comma-separated list of test IDs to skip. | | +| `severity_level` | Report only issues of a given severity level or higher. | `low` | +| `confidence_level` | Report only issues of a given confidence level or higher. | `low` | +| `verbose` | Output extra information like excluded and included files. | `false` | +| `debug` | Turn on debug mode. | `false` | +| `quiet` | Only show output in the case of an error. | `false` | +| `ignore_nosec` | Do not skip lines with `# nosec` comments. | `false` | +| `exclude_paths` | Comma-separated list of paths to exclude from scan. | | +| `baseline` | Path of a baseline report to compare against. | | +| `ini_path` | Path to a `.bandit` file that supplies command line args. | | +| `exit_zero` | Exit with 0 even with results found. + +| :memo: | We do not expose args for output/format,message_template, as we need to hardcore the report for the PR comment feature| +|---------------|:----------------------------------------------------------------------------------------------------------------------| + +## Usage + +To use the action, add the following to a GitHub workflow file (e.g. `.github/workflows/bandit.yml`): + +### Basic Example + +```yaml +name: Bandit Code Scan + +on: + pull_request: + branches: [ main ] + +permissions: + pull-requests: write + +jobs: + bandit-action: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run Bandit Scan + uses: ./ + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + path: "." + exit_zero: "true" + recursive: "true" +``` + +```yaml +name: Bandit Code Scan + +on: [push, pull_request] + +permissions: + pull-requests: write + +jobs: + bandit-action: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run Bandit Scan + uses: ./ + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + path: "." + exit_zero: true + recursive: true + aggregate: vuln + context_lines: 3 + config_file: .bandit + profile: bandit + tests: B101,B102 + skips: B103 + severity_level: low + confidence_level: low + verbose: true + debug: true + quiet: false + ignore_nosec: false + exclude_paths: tests,docs + baseline: baseline.json + ini_path: .bandit + exit_zero: false +``` + +## Contributing + +If you would like to contribute to this project, please open an issue or a pull +request. + +## License + +This GitHub Action is distributed under the Apache License, Version 2.0, see +[LICENSE](LICENSE) for more information. diff --git a/action.yml b/action.yml index 7e835d3..5bde7c9 100644 --- a/action.yml +++ b/action.yml @@ -1,82 +1,95 @@ -name: Bandit -description: Run Bandit -author: '@ericwb' - -inputs: - args: - description: | - Optional arguments: - -r, --recursive find and process files in subdirectories - -a {file,vuln}, --aggregate {file,vuln} - aggregate output by vulnerability (default) or by - filename - -n CONTEXT_LINES, --number CONTEXT_LINES - maximum number of code lines to output for each issue - -c CONFIG_FILE, --configfile CONFIG_FILE - optional config file to use for selecting plugins and - overriding defaults - -p PROFILE, --profile PROFILE - profile to use (defaults to executing all tests) - -t TESTS, --tests TESTS - comma-separated list of test IDs to run - -s SKIPS, --skip SKIPS - comma-separated list of test IDs to skip - -l, --level report only issues of a given severity level or higher - (-l for LOW, -ll for MEDIUM, -lll for HIGH) - --severity-level {all,low,medium,high} - report only issues of a given severity level or higher. - "all" and "low" are likely to produce the same results, - but it is possible for rules to be undefined which will - not be listed in "low". - -i, --confidence report only issues of a given confidence level or - higher (-i for LOW, -ii for MEDIUM, -iii for HIGH) - --confidence-level {all,low,medium,high} - report only issues of a given confidence level or higher. - "all" and "low" are likely to produce the same results, - but it is possible for rules to be undefined which will - not be listed in "low". - -f {csv,custom,html,json,screen,txt,xml,yaml}, --format {csv,custom,html,json,screen,txt,xml,yaml} - specify output format - --msg-template MSG_TEMPLATE - specify output message template (only usable with - --format custom), see CUSTOM FORMAT section for list - of available values - -o [OUTPUT_FILE], --output [OUTPUT_FILE] - write report to filename - -v, --verbose output extra information like excluded and included - files - -d, --debug turn on debug mode - -q, --quiet, --silent - only show output in the case of an error - --ignore-nosec do not skip lines with # nosec comments - -x EXCLUDED_PATHS, --exclude EXCLUDED_PATHS - comma-separated list of paths (glob patterns - supported) to exclude from scan (note that these are - in addition to the excluded paths provided in the - config file) (default: - .svn,CVS,.bzr,.hg,.git,__pycache__,.tox,.eggs,*.egg) - -b BASELINE, --baseline BASELINE - path of a baseline report to compare against (only - JSON-formatted files are accepted) - --ini INI_PATH path to a .bandit file that supplies command line - arguments - --exit-zero exit with 0, even with results found - --version show program's version number and exit - required: false - default: '-h' - targets: - description: | - Source file(s) or directory(s) to be tested +name: Bandit Code Scan +description: 'Run Bandit code scans on your Python codebase' +inputs: + GITHUB_TOKEN: + description: 'GitHub token' + required: true + recursive: + description: 'Find and process files in subdirectories' + required: false + default: 'false' + aggregate: + description: 'Aggregate output by vulnerability or by filename' + required: false + default: 'vuln' + context_lines: + description: 'Maximum number of code lines to output for each issue' + required: false + config_file: + description: 'Optional config file to use' + required: false + profile: + description: 'Profile to use' + required: false + tests: + description: 'Comma-separated list of test IDs to run' + required: false + skips: + description: 'Comma-separated list of test IDs to skip' + required: false + severity_level: + description: 'Report only issues of a given severity level or higher' + required: false + confidence_level: + description: 'Report only issues of a given confidence level or higher {all,low,medium,high}' + required: false + verbose: + description: 'Output extra information like excluded and included files' + required: false + default: 'false' + debug: + description: 'Turn on debug mode' + required: false + default: 'false' + quiet: + description: 'Only show output in the case of an error' + required: false + default: 'false' + ignore_nosec: + description: 'Do not skip lines with # nosec comments' + required: false + default: 'false' + exclude_paths: + description: 'Comma-separated list of paths to exclude from scan' + required: false + baseline: + description: 'Path of a baseline report to compare against' + required: false + ini_path: + description: 'Path to a .bandit file that supplies command line arguments' + required: false + path: + description: 'Path to scan' required: true - + default: '.' + level: + description: 'Report only issues of a given severity level or higher' + required: false + default: 'low' + exit_zero: + description: 'Exit with 0, even with results found' + required: false + default: 'false' runs: - using: docker - image: Dockerfile + using: 'docker' + image: 'Dockerfile' args: - - ${{ inputs.args }} - env: - TARGETS: ${{ inputs.targets }} - -branding: - icon: 'shield' - color: 'yellow' + - ${{ inputs.recursive }} + - ${{ inputs.aggregate }} + - ${{ inputs.context_lines }} + - ${{ inputs.config_file }} + - ${{ inputs.profile }} + - ${{ inputs.tests }} + - ${{ inputs.skips }} + - ${{ inputs.severity_level }} + - ${{ inputs.confidence_level }} + - ${{ inputs.verbose }} + - ${{ inputs.debug }} + - ${{ inputs.quiet }} + - ${{ inputs.ignore_nosec }} + - ${{ inputs.exclude_paths }} + - ${{ inputs.baseline }} + - ${{ inputs.ini_path }} + - ${{ inputs.path }} + - ${{ inputs.level }} + - ${{ inputs.exit_zero }} diff --git a/entrypoint.sh b/entrypoint.sh index 5321f2d..c1bfbe8 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,5 +1,87 @@ -#!/bin/sh +#!/bin/bash -set -e +# Use the INPUT_ prefixed environment variables that are passed by GitHub Actions +github_token=$INPUT_GITHUB_TOKEN +github_repository=$INPUT_GITHUB_REPOSITORY + +# Initialize the Bandit command +cmd="bandit" + +# Ensure INPUT_PATH is set, default to the current directory if not +INPUT_PATH=${INPUT_PATH:-.} + +# Check for the level or severity level +# Since -l and --severity-level cannot be used together, prioritize --severity-level if both are provided +if [ -n "${INPUT_SEVERITY_LEVEL}" ]; then + cmd+=" --severity-level $INPUT_SEVERITY_LEVEL" +elif [ -n "${INPUT_LEVEL}" ]; then + case "${INPUT_LEVEL}" in + "low") cmd+=" -l" ;; + "medium") cmd+=" -ll" ;; + "high") cmd+=" -lll" ;; + esac +fi + +# Check for the confidence input and set the confidence level +# Since -i and --confidence-level cannot be used together, prioritize --confidence-level if both are provided +if [ -n "${INPUT_CONFIDENCE_LEVEL}" ]; then + cmd+=" --confidence-level $INPUT_CONFIDENCE_LEVEL" +elif [ -n "${INPUT_CONFIDENCE}" ]; then + case "${INPUT_CONFIDENCE}" in + "low") cmd+=" -i" ;; + "medium") cmd+=" -ii" ;; + "high") cmd+=" -iii" ;; + esac +fi + +# Flags without parameters +[ "$INPUT_VERBOSE" = "true" ] && cmd+=" -v" +[ "$INPUT_DEBUG" = "true" ] && cmd+=" -d" +[ "$INPUT_QUIET" = "true" ] && cmd+=" -q" +[ "$INPUT_IGNORE_NOSEC" = "true" ] && cmd+=" --ignore-nosec" +[ "$INPUT_EXIT_ZERO" = "true" ] && cmd+=" --exit-zero" + +# Other flags with parameters +[ -n "$INPUT_AGGREGATE" ] && cmd+=" -a $INPUT_AGGREGATE" +[ -n "$INPUT_CONTEXT_LINES" ] && cmd+=" -n $INPUT_CONTEXT_LINES" +[ -n "$INPUT_CONFIG_FILE" ] && cmd+=" -c $INPUT_CONFIG_FILE" +[ -n "$INPUT_PROFILE" ] && cmd+=" -p $INPUT_PROFILE" +[ -n "$INPUT_TESTS" ] && cmd+=" -t $INPUT_TESTS" +[ -n "$INPUT_SKIPS" ] && cmd+=" -s $INPUT_SKIPS" +[ -n "$INPUT_EXCLUDE_PATHS" ] && cmd+=" -x $INPUT_EXCLUDE_PATHS" +[ -n "$INPUT_BASELINE" ] && cmd+=" -b $INPUT_BASELINE" +[ -n "$INPUT_INI_PATH" ] && cmd+=" --ini $INPUT_INI_PATH" + +# Set INPUT_RECURSIVE with INPUT_PATH. We hardcode -r as it is required for Bandit to run +[ "$INPUT_RECURSIVE" = "true" ] && cmd+=" -r $INPUT_PATH" + +# Echo the final command +echo "Constructed command: $cmd" + + +# Force the output format as JSON and output file, we default to json and to +# report.json as this is required to format the output for the post_comment.py +# script +cmd+=" -f json -o report.json" + +# Run the Bandit command +echo "Executing command: $cmd" +eval $cmd + +# Capture the exit code from Bandit to either pass or fail the GitHub Action +bandit_exit_code=$? + +GITHUB_TOKEN=$GITHUB_TOKEN GITHUB_REPOSITORY=$GITHUB_REPOSITORY python /post_comment.py + +# Check if exit_zero is set to "true" +if [ "$INPUT_EXIT_ZERO" = "true" ]; then + echo "exit_zero is set to true. Exiting with code 0 regardless of Bandit findings." + exit 0 +else + # If Bandit exited with a non-zero exit code and exit_zero is not true, exit this script with the Bandit exit code + if [ $bandit_exit_code -ne 0 ]; then + echo "Bandit found issues and exit_zero is not set to true. Exiting with code $bandit_exit_code." + exit $bandit_exit_code + fi +fi -sh -c "bandit $*" diff --git a/post_comment.py b/post_comment.py new file mode 100644 index 0000000..d894ed5 --- /dev/null +++ b/post_comment.py @@ -0,0 +1,77 @@ +import os +from github import Github +import json + +# Define emoji for each severity level +severity_emoji = { + "HIGH": "🔴", + "MED": "🟠", + "LOW": "🟡", + "UNDEF": "⚪" +} + +# Access the GITHUB_TOKEN environment variable +github_token = os.getenv('GITHUB_TOKEN') +if not github_token: + raise Exception('GITHUB_TOKEN is not set or empty') + +# Initialize the GitHub client with the token +g = Github(github_token) + +# Get the repository and pull request objects +repo = g.get_repo(os.getenv('GITHUB_REPOSITORY')) +pr_number = int(os.getenv('GITHUB_REF').split('/')[-2]) +pr = repo.get_pull(pr_number) + +# Read the Bandit report +with open('report.json', 'r') as file: + report_data = json.load(file) + +# Start formatting the comment +comment = "## 🛡️ Bandit Scan Results Summary\n\n" + +# Prepare a summary of findings +severity_counts = {"HIGH": 0, "MEDIUM": 0, "LOW": 0, "UNDEFINED": 0} +for result in report_data.get('results', []): + severity_counts[result['issue_severity']] += 1 + +comment += f"We found **{severity_counts['HIGH']} High**, **{severity_counts['MEDIUM']} Medium**, and **{severity_counts['LOW']} Low** severity issues. \n\n" + +# Add detailed findings header +comment += "### Detailed Findings\n---\n" + +# Add table header +comment += "| Severity | Issue | File | Line | Confidence | More Info | Test ID |\n" +comment += "| -------- | ----- | ---- | ---- | ---------- | --------- | ------- |\n" + +# Iterate through the results and add table rows +for result in report_data.get('results', []): + severity = result['issue_severity'] + issue_text = result['issue_text'] + filename = result['filename'] + line_number = result['line_range'][0] + confidence = result['issue_confidence'] + more_info_url = result['more_info'] + test_id = result['test_id'] + # Add row to the comment with the new columns + comment += f"| {severity_emoji.get(severity, '⚪')} {severity} | {issue_text} | {filename} | {line_number} | {confidence} | [More Info]({more_info_url}) | {test_id} |\n" + +comment += "\n---\n" + +# Add collapsible section for recommendations +comment += "
\n:sparkles: About this Report\n\n" +comment += "This report was generated by the [official Bandit GitHub Action](#link-to-action) to ensure our codebase stays secure.\n" +comment += "
\n\n" + +comment += "
\n:closed_book: What is Bandit?\n\n" +comment += "Bandit is a tool designed to find common security issues in Python code. To learn more about how Bandit helps to keep Python code safe, visit the [Bandit documentation](https://bandit.readthedocs.io/).\n" +comment += "
\n\n" + +comment += "
\n:busts_in_silhouette: Community Support\n\n" +comment += "Got questions or need help with Bandit Action?\n" +comment += "- Join our community on the [Discord server](https://discord.gg/D3RTpU9zEj).\n" +comment += "- Share tips, get advice, and collaborate on security best practices.\n" +comment += "
\n" + +# Post the comment +pr.create_issue_comment(comment) diff --git a/test/main.py b/test/main.py new file mode 100644 index 0000000..ff2c1af --- /dev/null +++ b/test/main.py @@ -0,0 +1,34 @@ +import subprocess +import yaml + +# hard-coded secret +SECRET_KEY = "this_is_a_secret_key" + +def insecure_deserialization(data): + # Insecure deserialization example + import pickle + return pickle.loads(data) + +def insecure_subprocess_command(user_input): + # Insecure subprocess call with shell=True, which is vulnerable to shell injection + command = "echo " + user_input + subprocess.call(command, shell=True) + +def unsafe_yaml_dump(data): + # Unsafe YAML dump, which is vulnerable to arbitrary code execution + ystr = yaml.dump({'a' : 1, 'b' : 2, 'c' : 3}) + y = yaml.load(ystr) + return yaml.dump(y) + +def main(): + data = b"cos\nsystem\n(S'echo Hello world!'\ntR." # Malicious pickle data + user_input = "user_input_here; rm -rf /" # Malicious user input + + # Calling functions with security issues + result = insecure_deserialization(data) + insecure_subprocess_command(user_input) + + print("Result of insecure deserialization:", result) + +if __name__ == "__main__": + main()