mirror of
https://github.com/mandiant/capa-rules
synced 2026-06-08 15:41:20 +00:00
228 lines
7.0 KiB
Python
228 lines
7.0 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
examples: %(prog)s v5.0.0 | %(prog)s -p v1.0.0 v2.0.0 | %(prog)s -c v3.0.0 -r test/capa-rules
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import logging
|
|
import subprocess
|
|
import collections
|
|
from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
|
|
|
|
GIT_EXE = "git"
|
|
DIFF_TYPE = {
|
|
"A": "Added",
|
|
"M": "Modified",
|
|
"R": "Renamed",
|
|
"D": "Deleted",
|
|
}
|
|
DEFAULT_PRIOR_TAG = "(prior_tag)"
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def run_cmd(cmd: str) -> tuple[str, str]:
|
|
logger.debug("cmd: %s", cmd)
|
|
p = subprocess.Popen(cmd.split(" "), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
out_, err_ = p.communicate()
|
|
out = out_.decode("utf-8").strip()
|
|
err = err_.decode("utf-8").strip()
|
|
logger.debug("cmd out: %s", out)
|
|
logger.debug("cmd err: %s", err)
|
|
return out, err
|
|
|
|
|
|
def get_diffs(cpath1: str, cpath2: str, percentage: str) -> dict[str, list]:
|
|
cmd = f"{GIT_EXE} --no-pager diff --find-renames={percentage} --name-status {cpath1} {cpath2}"
|
|
gdiff, err = run_cmd(cmd)
|
|
# example output:
|
|
# D load-code/pe/parse-pe-exports.yml
|
|
# M load-code/pe/rebuild-import-table.yml
|
|
# A load-code/pe/resolve-function-by-parsing-pe-exports.yml
|
|
# R055 nursery/run-powershell-expression.yml load-code/powershell/run-powershell-expression.yml
|
|
if err:
|
|
raise ValueError(f"{cmd}\n{err}")
|
|
|
|
diffs = collections.defaultdict(list)
|
|
for line in gdiff.splitlines():
|
|
# only care about rules
|
|
if ".yml" not in line:
|
|
continue
|
|
# but this directory may also contain yml files
|
|
if ".github" in line:
|
|
continue
|
|
|
|
try:
|
|
change, filenames = line.split("\t", 1)
|
|
except ValueError as e:
|
|
raise ValueError(f"{e}: {line}")
|
|
|
|
change = change[0]
|
|
if change in ("A", "M", "D"):
|
|
# should only list one file
|
|
assert isinstance(filenames, str)
|
|
diffs[DIFF_TYPE[change]].append(filenames)
|
|
elif change == "R":
|
|
# should list two files
|
|
fs = filenames.split("\t")
|
|
assert len(fs) == 2
|
|
old, new = fs
|
|
diffs[DIFF_TYPE[change]].append((old, new))
|
|
else:
|
|
raise ValueError(f"change type not handled: {line}")
|
|
|
|
return diffs
|
|
|
|
|
|
def format_diffs(repo: str, cpath1: str, cpath2: str, percentage: str) -> str:
|
|
diffs = get_diffs(cpath1, cpath2, percentage)
|
|
|
|
result = list()
|
|
result.append("## Summary")
|
|
for change in DIFF_TYPE.values():
|
|
count = len(diffs.get(change, []))
|
|
r = "rule" if count == 1 else "rules"
|
|
result.append(f"{change}: {count} {r}")
|
|
result.append(
|
|
f"\nDetailed release changes: [rules {cpath1}...{cpath2}](https://github.com/{repo}/compare/{cpath1}...{cpath2})"
|
|
)
|
|
|
|
for change in DIFF_TYPE.values():
|
|
if change in diffs:
|
|
result.append(f"\n## {change} rules ({len(diffs[change])})")
|
|
for f in diffs[change]:
|
|
if change == "Renamed":
|
|
old, new = f
|
|
result.append(
|
|
f"- [{new}](https://github.com/{repo}/blob/{cpath2}/{new}) (was [{old}](https://github.com/{repo}/blob/{cpath1}/{old}))"
|
|
)
|
|
elif change == "Deleted":
|
|
result.append(f"- [{f}](https://github.com/{repo}/blob/{cpath1}/{f})")
|
|
else:
|
|
result.append(f"- [{f}](https://github.com/{repo}/blob/{cpath2}/{f})")
|
|
|
|
return "\n".join(result)
|
|
|
|
|
|
def get_repo(repo: str):
|
|
# pip install PyGithub
|
|
# only need this for manual release creations, so import only here
|
|
from github import Github
|
|
|
|
logger.info("connecting to GitHub repo %s", repo)
|
|
# github_pat_... or ghp_...
|
|
CAPA_TOKEN = os.getenv("CAPA_TOKEN")
|
|
if CAPA_TOKEN is None:
|
|
raise ValueError("must set GitHub token in CAPA_TOKEN environment variable")
|
|
g = Github(CAPA_TOKEN)
|
|
return g.get_repo(repo)
|
|
|
|
|
|
def create_releases(repo_name: str, prior_tag: str, release_tag: str, create: bool, percentage: str):
|
|
commits, _ = run_cmd(f"{GIT_EXE} rev-list HEAD")
|
|
# last will be first
|
|
# need this to compare the very first tag
|
|
initial_commit = commits.splitlines()[-1]
|
|
tags = [initial_commit]
|
|
|
|
git_tags, _ = run_cmd(f"{GIT_EXE} tag -l v*.*")
|
|
tags.extend(git_tags.splitlines())
|
|
logger.debug("tags: %s", tags)
|
|
|
|
try:
|
|
end = tags.index(release_tag)
|
|
if prior_tag == DEFAULT_PRIOR_TAG:
|
|
# one tag before release tag
|
|
start = end - 1
|
|
else:
|
|
start = tags.index(prior_tag)
|
|
except ValueError as e:
|
|
logger.error("%s: %s", e, tags)
|
|
return
|
|
|
|
logger.info("creating %d release(s)", end - start)
|
|
repo = None
|
|
for n in range(start, end):
|
|
prior_tag = tags[n]
|
|
release_tag = tags[n + 1]
|
|
|
|
logger.info("creating release text for tag %s (diff to %s)", release_tag, prior_tag)
|
|
fdiffs = format_diffs(repo_name, prior_tag, release_tag, percentage)
|
|
if create:
|
|
if not repo:
|
|
repo = get_repo(repo_name)
|
|
logger.info("creating GitHub release for tag %s", release_tag)
|
|
repo.create_git_release(tag=release_tag, name=release_tag, message=fdiffs)
|
|
else:
|
|
print(fdiffs)
|
|
|
|
|
|
def main(argv=None):
|
|
if argv is None:
|
|
argv = sys.argv[1:]
|
|
|
|
parser = ArgumentParser(
|
|
description="format release details and create releases (-c argument) via GitHub API",
|
|
epilog=__doc__,
|
|
formatter_class=ArgumentDefaultsHelpFormatter,
|
|
)
|
|
parser.add_argument(
|
|
"release_tag",
|
|
help="tag name to create release for, tag must exist",
|
|
)
|
|
parser.add_argument(
|
|
"-p",
|
|
"--prior_tag",
|
|
default=DEFAULT_PRIOR_TAG,
|
|
help="path/name of commit/tag prior to release_tag, use to create multiple releases for tag range, "
|
|
"use 'empty' for initial commit",
|
|
)
|
|
parser.add_argument(
|
|
"--percentage",
|
|
type=str,
|
|
default="20",
|
|
help="diff find-renames percentage to identify renamed files, default works well in most cases",
|
|
)
|
|
parser.add_argument(
|
|
"-c",
|
|
"--create",
|
|
action="store_true",
|
|
help="create releases for tags in range, tags must exist",
|
|
)
|
|
parser.add_argument(
|
|
"-r",
|
|
"--repo",
|
|
type=str,
|
|
default="mandiant/capa-rules",
|
|
help="GitHub repository to use",
|
|
)
|
|
parser.add_argument(
|
|
"-d",
|
|
"--debug",
|
|
action="store_true",
|
|
help="enable debugging output on STDERR",
|
|
)
|
|
|
|
args = parser.parse_args(args=argv)
|
|
|
|
if args.debug:
|
|
log_level = logging.DEBUG
|
|
else:
|
|
log_level = logging.INFO
|
|
|
|
logging.basicConfig(level=log_level)
|
|
logging.getLogger().setLevel(log_level)
|
|
|
|
try:
|
|
create_releases(args.repo, args.prior_tag, args.release_tag, args.create, args.percentage)
|
|
except ValueError as e:
|
|
logger.error("%s", e)
|
|
return -1
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|