|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +# Copyright (C) 2024 Red Hat, Inc. |
| 4 | +# |
| 5 | +# This file is part of csdiff. |
| 6 | +# |
| 7 | +# csdiff is free software: you can redistribute it and/or modify |
| 8 | +# it under the terms of the GNU General Public License as published by |
| 9 | +# the Free Software Foundation, either version 3 of the License, or |
| 10 | +# any later version. |
| 11 | +# |
| 12 | +# csdiff is distributed in the hope that it will be useful, |
| 13 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 14 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 15 | +# GNU General Public License for more details. |
| 16 | +# |
| 17 | +# You should have received a copy of the GNU General Public License |
| 18 | +# along with csdiff. If not, see <http://www.gnu.org/licenses/>. |
| 19 | + |
| 20 | +import argparse |
| 21 | +import os |
| 22 | +import re |
| 23 | +import shlex |
| 24 | +import subprocess |
| 25 | +import sys |
| 26 | + |
| 27 | + |
| 28 | +# if neither --kfp-dir nor --kfp-git-url is specified, use the known-false-positives RPM package |
| 29 | +DEFAULT_KFP_DIR = "/usr/share/csmock/known-false-positives.d" |
| 30 | +DEFAULT_KFP_JSON = "/usr/share/csmock/known-false-positives.js" |
| 31 | + |
| 32 | + |
| 33 | +def construct_init_cmd(args): |
| 34 | + # make bash exit on error |
| 35 | + cmd = 'set -e\n' |
| 36 | + |
| 37 | + # make bash propagate exit code from piped commands |
| 38 | + cmd += 'set -o pipefail\n' |
| 39 | + |
| 40 | + # make bash expand empty globs |
| 41 | + cmd += 'shopt -s nullglob\n' |
| 42 | + |
| 43 | + # create a temporary directory with an automatic destructor |
| 44 | + cmd += 'export td=$(mktemp --directory --tmpdir tmp-csfilter-kfp.XXXXXXXXXX)\n' |
| 45 | + cmd += 'trap "rm -fr \'${td}\'" EXIT\n' |
| 46 | + |
| 47 | + if args.verbose: |
| 48 | + # run shell in XTRACE mode |
| 49 | + cmd += 'set -x\n' |
| 50 | + |
| 51 | + return cmd |
| 52 | + |
| 53 | + |
| 54 | +def construct_git_cmd(kfp_git_url): |
| 55 | + # split kfp_git_url into the clone URL and (optional) revision |
| 56 | + m = re.match("^(.*)#([0-9a-f]+)", kfp_git_url) |
| 57 | + if m: |
| 58 | + # checkout a specific revision |
| 59 | + url = shlex.quote(m.group(1)) |
| 60 | + rev = m.group(2) |
| 61 | + return f'git clone {url} ${{td}}/kfp\n' \ |
| 62 | + f'git -C "${{td}}/kfp" reset -q --hard {rev}\n' |
| 63 | + else: |
| 64 | + # shallow clone of the default branch |
| 65 | + url = shlex.quote(kfp_git_url) |
| 66 | + return f'git clone --depth 1 {url} "${{td}}/kfp"\n' |
| 67 | + |
| 68 | + |
| 69 | +def construct_prep_cmd(args): |
| 70 | + # check which KFP will be used |
| 71 | + have_kfp_json = False |
| 72 | + if args.kfp_git_url: |
| 73 | + # clone git repo |
| 74 | + cmd = construct_git_cmd(args.kfp_git_url) |
| 75 | + elif args.kfp_dir: |
| 76 | + # symlink an absolute path to the directory |
| 77 | + kfp_abs = shlex.quote(os.path.realpath(args.kfp_dir)) |
| 78 | + cmd = f'ln -s {kfp_abs} "${{td}}/kfp"\n' |
| 79 | + elif os.path.isfile(DEFAULT_KFP_JSON): |
| 80 | + # create symlinks to the known-false-positives RPM package installed on the system |
| 81 | + cmd = f'ln -s "{DEFAULT_KFP_DIR}" "${{td}}/kfp"\n' \ |
| 82 | + f'ln -s "{DEFAULT_KFP_JSON}" "${{td}}/kfp.json"\n' |
| 83 | + have_kfp_json = True |
| 84 | + else: |
| 85 | + raise RuntimeError("no source of KFP specified, please use --kfp-dir or --kfp-git-url" \ |
| 86 | + " (or install the known-false-positives RPM pacakge)") |
| 87 | + |
| 88 | + if not have_kfp_json: |
| 89 | + # create all-in-one kfp.json file from files in ${td}/kfp |
| 90 | + cmd += 'touch "${td}/empty.err"\n' |
| 91 | + cmd += '(cd "${td}/kfp" && csgrep --mode=json --remove-duplicates ${td}/empty.err' |
| 92 | + cmd += ' */ignore.err */true-positives-ignore.err >"${td}/kfp.json")\n' |
| 93 | + |
| 94 | + return cmd |
| 95 | + |
| 96 | + |
| 97 | +def construct_path_filter(args): |
| 98 | + if args.project_nvr is None: |
| 99 | + # TODO: read project_nvr from scan properties if available |
| 100 | + return ' cat\n' |
| 101 | + |
| 102 | + # cut off the `-version-release` or `-version` suffix to obtain package name where `version` can be |
| 103 | + # a number optionally prefixed by `v` or a full-size SHA1 hash encoded in lowercase as, for example, |
| 104 | + # in `project-koku-koku-cbe5e5c3355c1e140aa1cca7377aebe09d8d8466` |
| 105 | + proj = re.sub("-(([v]?[0-9][^-]*)|([0-9a-f]{40}))(-[0-9][^-]*)?$", "", args.project_nvr) |
| 106 | + |
| 107 | + # validate the resulting project name |
| 108 | + if not re.match("^[A-Za-z0-9-_]+$", proj): |
| 109 | + raise RuntimeError(f"invalid project name: {proj}") |
| 110 | + |
| 111 | + # generate a script that will construct the filter at run-time |
| 112 | + cmd = f' ep="${{td}}/kfp/{proj}/exclude-paths.txt"\n' |
| 113 | + cmd += ' re=\n' |
| 114 | + cmd += ' while read line; do\n' |
| 115 | + cmd += ' re="${re}|(${line})"\n' |
| 116 | + cmd += ' done < <(grep -Esv "^(#|\\\\$)" "$ep")\n' |
| 117 | + cmd += ' if test -n "$re"; then\n' |
| 118 | + cmd += ' csgrep --mode=json --invert-match --path="${re#|}"\n' |
| 119 | + cmd += ' else\n' |
| 120 | + cmd += ' cat\n' |
| 121 | + cmd += ' fi\n' |
| 122 | + return cmd |
| 123 | + |
| 124 | + |
| 125 | +def construct_filter_cmd(args): |
| 126 | + # set shell options and create a temporary diretory ${td} |
| 127 | + cmd = construct_init_cmd(args) |
| 128 | + |
| 129 | + # prepare the KFP data from the specified source |
| 130 | + cmd += construct_prep_cmd(args) |
| 131 | + |
| 132 | + # read the whole input into a JSON file |
| 133 | + cmd += 'csgrep --mode=json' |
| 134 | + if args.input_file: |
| 135 | + input_file = shlex.quote(args.input_file) |
| 136 | + cmd += f' {input_file}' |
| 137 | + cmd += ' >"${td}/input.json"\n' |
| 138 | + |
| 139 | + # define path-based filter |
| 140 | + path_filter = construct_path_filter(args) |
| 141 | + cmd += f'path_filter() {{\n{path_filter}}}\n' |
| 142 | + |
| 143 | + # exclude individual findings |
| 144 | + cmd += 'csdiff --show-internal "${td}/kfp.json" "${td}/input.json"' |
| 145 | + |
| 146 | + # exclude paths in the scan results |
| 147 | + cmd += ' | path_filter >${td}/output.json\n' |
| 148 | + |
| 149 | + if args.record_excluded: |
| 150 | + # record excluded findings to the specified file |
| 151 | + excluded_file = shlex.quote(args.record_excluded) |
| 152 | + cmd += 'csdiff "${td}/output.json" "${td}/input.json"' |
| 153 | + cmd += f' >{excluded_file}\n' |
| 154 | + |
| 155 | + if not args.json_output: |
| 156 | + # export plain-text format |
| 157 | + cmd += 'csgrep "${td}/output.json"\n' |
| 158 | + return cmd |
| 159 | + |
| 160 | + # export JSON format |
| 161 | + cmd += 'csgrep --mode=json "${td}/output.json"' |
| 162 | + |
| 163 | + # optionally record the source of known-false-positives |
| 164 | + if args.kfp_dir: |
| 165 | + kfp_dir = shlex.quote(args.kfp_dir) |
| 166 | + cmd += f' --set-scan-prop=known-false-positives-dir:{kfp_dir}' |
| 167 | + elif args.kfp_git_url: |
| 168 | + kfp_git_url = shlex.quote(args.kfp_git_url) |
| 169 | + cmd += f' --set-scan-prop=known-false-positives-git-url:{kfp_git_url}' |
| 170 | + cmd += '\n' |
| 171 | + |
| 172 | + return cmd |
| 173 | + |
| 174 | + |
| 175 | +def main(): |
| 176 | + # initialize argument parser |
| 177 | + parser = argparse.ArgumentParser() |
| 178 | + |
| 179 | + parser.add_argument( |
| 180 | + "input_file", nargs="?", |
| 181 | + help="optional name of the input file (standard input is used by default)") |
| 182 | + |
| 183 | + # source of known-false-positives |
| 184 | + kfp_source = parser.add_mutually_exclusive_group() |
| 185 | + kfp_source.add_argument( |
| 186 | + "--kfp-dir", |
| 187 | + help="known false positives directory") |
| 188 | + kfp_source.add_argument( |
| 189 | + "--kfp-git-url", |
| 190 | + help="known false positives git URL (optionally taking a revision delimited by #)") |
| 191 | + |
| 192 | + parser.add_argument( |
| 193 | + "--project-nvr", |
| 194 | + help="Name-Version-Release (NVR) of the scanned project, used to find path exclusions") |
| 195 | + |
| 196 | + parser.add_argument( |
| 197 | + "--record-excluded", |
| 198 | + help="file to store all excluded findings to") |
| 199 | + |
| 200 | + parser.add_argument( |
| 201 | + "--json-output", action="store_true", default=(not sys.stdout.isatty()), |
| 202 | + help="produce JSON output (default if stdout is not connected to a terminal)") |
| 203 | + |
| 204 | + parser.add_argument( |
| 205 | + "-v", "--verbose", action="store_true", |
| 206 | + help="run shell in XTRACE mode while executing the filtering script") |
| 207 | + |
| 208 | + parser.add_argument( |
| 209 | + "-n", "--dry-run", action="store_true", |
| 210 | + help="do not execute anything, only print the shell script that would be executed") |
| 211 | + |
| 212 | + # parse command-line arguments |
| 213 | + args = parser.parse_args() |
| 214 | + |
| 215 | + # if --kfp-dir is used, check that a directory was given |
| 216 | + if args.kfp_dir and not os.path.isdir(args.kfp_dir): |
| 217 | + parser.error(f"'{args.kfp_dir}' given to --kfp-dir is not a directory") |
| 218 | + |
| 219 | + # construct the command to filter |
| 220 | + try: |
| 221 | + cmd = construct_filter_cmd(args) |
| 222 | + except RuntimeError as e: |
| 223 | + parser.error(e) |
| 224 | + |
| 225 | + if args.dry_run: |
| 226 | + # print the command and exit successfully |
| 227 | + print(cmd, end='') |
| 228 | + sys.exit(0) |
| 229 | + |
| 230 | + # run the command |
| 231 | + try: |
| 232 | + subprocess.run(cmd, shell=True, check=True, executable='/bin/bash') |
| 233 | + except subprocess.CalledProcessError as e: |
| 234 | + sys.exit(e.returncode) |
| 235 | + |
| 236 | + |
| 237 | +if __name__ == "__main__": |
| 238 | + main() |
0 commit comments