Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion bbot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ async def _main():
# that don't construct a full Scanner.
preset.apply_log_level(apply_core=True)

# which generated config files the user is regenerating this run
reset_labels = [
label
for label, requested in (("config", options.reset_config), ("secrets", options.reset_secrets))
if requested
]

# print help if no arguments
if len(sys.argv) == 1:
print(preset.args.parser.format_help())
Expand All @@ -80,6 +87,41 @@ async def _main():
sys.exit(0)
return

# --reset-config / --reset-secrets
if reset_labels:
reset_paths = [
spec["path"]
for spec in preset.module_loader._generated_config_files()
if spec["label"] in reset_labels
]
log.hugewarning(
"Regenerating from current defaults. Any settings you have customized "
"(uncommented) in these files WILL BE WIPED OUT:"
)
for p in reset_paths:
log.warning(f" {p}")
log.warning("A backup of each existing file will be saved with a .bak extension.")
try:
stdin_is_tty = sys.stdin.isatty()
except (ValueError, io.UnsupportedOperation):
stdin_is_tty = False
if not options.yes:
if not stdin_is_tty:
log.error("Refusing to reset config without confirmation; re-run with --yes to proceed.")
sys.exit(1)
return
answer = input("Continue? [y/N] ").strip().lower()
if answer not in ("y", "yes"):
log.info("Aborted. No changes made.")
sys.exit(0)
return
backups = preset.module_loader.reset_config_files(reset_labels)
log.success("Regenerated config files from current defaults.")
for b in backups:
log.info(f"Backup saved: {b}")
sys.exit(0)
return

# --list-presets
if options.list_presets:
print("")
Expand Down Expand Up @@ -157,7 +199,32 @@ async def _main():
print(row)
return

preset.validate()
try:
preset.validate()
except ValidationError as e:
log.error(str(e))
# if a bad option actually lives in one of the user's generated
# config files (vs, say, a -c CLI typo), point them at the matching
# reset flag -- validate each file's own contents to be sure
import yaml
from bbot.scanner.preset.validate import validate_preset

for spec in preset.module_loader._generated_config_files():
if not spec["path"].exists():
continue
try:
file_config = yaml.safe_load(spec["path"].read_text()) or {}
except yaml.YAMLError:
continue
if isinstance(file_config, dict) and validate_preset(
{"config": file_config}, module_loader=preset.module_loader
):
log.warning(
f"Some options in {spec['path']} are not recognized. They may be left over from an "
f"older version of BBOT. You have the option of regenerating from current defaults "
f"with: bbot {spec['reset_flag']}"
)
return
baked_preset = preset.bake()

# --current-preset / --current-preset-full
Expand Down
34 changes: 31 additions & 3 deletions bbot/core/config/files.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import os
import sys
import yaml
import atexit
import shutil
import tempfile
from pathlib import Path

from .merge import deep_merge
Expand All @@ -9,15 +13,39 @@

bbot_code_dir = Path(__file__).parent.parent.parent

# cached per-process so every BBOTConfigFiles in a run resolves to the same dir
_test_config_dir = None


def isolated_test_config_dir():
"""A throwaway config dir for tests, so we never read or write the user's
real ~/.config/bbot. It's created fresh per run (so previous or concurrent
runs can't interfere) and shared across the run's processes via an env var
so spawned children resolve to the same dir."""
global _test_config_dir
if _test_config_dir is None:
env_dir = os.environ.get("BBOT_TEST_CONFIG_DIR")
if env_dir:
_test_config_dir = Path(env_dir)
else:
_test_config_dir = Path(tempfile.mkdtemp(prefix="bbot_test_config_"))
os.environ["BBOT_TEST_CONFIG_DIR"] = str(_test_config_dir)
atexit.register(lambda: shutil.rmtree(_test_config_dir, ignore_errors=True))
return _test_config_dir


class BBOTConfigFiles:
config_dir = (Path.home() / ".config" / "bbot").resolve()
defaults_filename = (bbot_code_dir / "defaults.yml").resolve()
config_filename = (config_dir / "bbot.yml").resolve()
secrets_filename = (config_dir / "secrets.yml").resolve()

def __init__(self, core):
self.core = core
if os.environ.get("BBOT_TESTING", "") == "True":
base_dir = isolated_test_config_dir()
else:
base_dir = Path.home() / ".config" / "bbot"
self.config_dir = base_dir.resolve()
self.config_filename = (self.config_dir / "bbot.yml").resolve()
self.secrets_filename = (self.config_dir / "secrets.yml").resolve()

def _get_config(self, filename, name="config") -> dict:
filename = Path(filename).resolve()
Expand Down
135 changes: 112 additions & 23 deletions bbot/core/modules.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import os
import re
import ast
import sys
import stat
import yaml
import atexit
import shutil
import pickle
import logging
import tempfile
import importlib
import traceback
from copy import copy
Expand Down Expand Up @@ -1059,35 +1063,120 @@ def filter_modules(self, modules=None, mod_type=None):
module_list.sort(key=lambda x: x[-1]["type"], reverse=True)
return module_list

def ensure_config_files(self):
def _generated_config_files(self):
"""The config files BBOT generates, each paired with the content it
should currently hold and the CLI flag that regenerates it.

Creation and reset both iterate this list, so the two files stay fully
independent: a `secrets.yml` full of API keys is never touched just
because `bbot.yml`'s options changed, and vice versa.
"""
files = self.core.files_config
mkdir(files.config_dir)
config_obj = dict(self.core.default_config)
return [
{
"label": "config",
"path": files.config_filename,
"content": self.core.no_secrets_config(config_obj),
"secret": False,
"reset_flag": "--reset-config",
},
{
"label": "secrets",
"path": files.secrets_filename,
"content": self.core.secrets_only_config(config_obj),
"secret": True,
"reset_flag": "--reset-secrets",
},
]

comment_notice = (
"# NOTICE: THESE ENTRIES ARE COMMENTED BY DEFAULT\n"
+ "# Please be sure to uncomment when inserting API keys, etc.\n"
)
def ensure_config_files(self):
"""Create any of the user's generated config files that are missing.

config_obj = dict(self.core.default_config)
Each file is a fully-commented snapshot of the current defaults,
written once and never overwritten.
"""
mkdir(self.core.files_config.config_dir)
for spec in self._generated_config_files():
if not spec["path"].exists():
log_to_stderr(f"Creating BBOT {spec['label']} at {spec['path']}")
self._write_config_template(spec["path"], spec["content"], secret=spec["secret"])

def reset_config_files(self, labels):
"""Regenerate the named generated config files (`"config"` and/or
`"secrets"`) from current defaults, backing up existing files to
`*.bak`. Returns the backup paths.

Destructive: a regenerated file is a fresh commented template, so any
options the user uncommented in it are not carried over.
"""
mkdir(self.core.files_config.config_dir)
backups = []
for spec in self._generated_config_files():
if spec["label"] not in labels:
continue
path = spec["path"]
if path.exists():
backup = self._next_backup_path(path)
# copy2 preserves the file's permissions, so a backup of a
# hardened secrets.yml stays just as locked-down
shutil.copy2(path, backup)
backups.append(backup)
self._write_config_template(path, spec["content"], secret=spec["secret"])
return backups

# ensure bbot.yml
if not files.config_filename.exists():
log_to_stderr(f"Creating BBOT config at {files.config_filename}")
no_secrets_config = self.core.no_secrets_config(config_obj)
yaml_str = yaml.dump(no_secrets_config, sort_keys=False)
yaml_str = comment_notice + "\n".join(f"# {line}" for line in yaml_str.splitlines())
with open(str(files.config_filename), "w") as f:
@staticmethod
def _next_backup_path(path):
"""First free `<name>.bak`, `<name>.bak.1`, `<name>.bak.2`, ... so an
existing backup from a previous reset isn't clobbered."""
backup = path.with_name(path.name + ".bak")
i = 1
while backup.exists():
backup = path.with_name(f"{path.name}.bak.{i}")
i += 1
return backup

@classmethod
def _write_config_template(cls, path, config_dict, secret=False):
header = (
"# NOTICE: THESE ENTRIES ARE COMMENTED BY DEFAULT\n"
"# Please be sure to uncomment when inserting API keys, etc.\n"
)
yaml_str = yaml.dump(config_dict, sort_keys=False)
yaml_str = header + "\n".join(f"# {line}" for line in yaml_str.splitlines())
if secret:
cls._write_secret_text(path, yaml_str)
else:
with open(str(path), "w") as f:
f.write(yaml_str)

# ensure secrets.yml
if not files.secrets_filename.exists():
log_to_stderr(f"Creating BBOT secrets at {files.secrets_filename}")
secrets_only_config = self.core.secrets_only_config(config_obj)
yaml_str = yaml.dump(secrets_only_config, sort_keys=False)
yaml_str = comment_notice + "\n".join(f"# {line}" for line in yaml_str.splitlines())
with open(str(files.secrets_filename), "w") as f:
f.write(yaml_str)
files.secrets_filename.chmod(0o600)
@staticmethod
def _write_secret_text(path, text):
"""Write a secrets file so it is never readable by anyone but the owner,
even for an instant. The content is written to a private temp file
(mkstemp creates it 0600) and atomically renamed into place; an existing
file's own permissions are preserved in case the user hardened them
further (e.g. 0400). If owner-only permissions can't be guaranteed, the
secret is not written."""
path = Path(path)
mode = 0o600
with suppress(FileNotFoundError):
existing_mode = stat.S_IMODE(path.stat().st_mode)
# keep the user's perms only if they're already owner-only
if not existing_mode & 0o077:
mode = existing_mode
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
try:
os.fchmod(fd, mode)
with os.fdopen(fd, "w") as f:
f.write(text)
if stat.S_IMODE(os.stat(tmp).st_mode) & 0o077:
raise BBOTError(f"Refusing to write secrets to {path}: could not restrict permissions to owner-only")
os.replace(tmp, str(path))
except BaseException:
with suppress(FileNotFoundError):
os.unlink(tmp)
raise


MODULE_LOADER = ModuleLoader()
10 changes: 10 additions & 0 deletions bbot/scanner/preset/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,16 @@ def create_parser(self, *args, **kwargs):

misc = p.add_argument_group(title="Misc")
misc.add_argument("--version", action="store_true", help="show BBOT version and exit")
misc.add_argument(
"--reset-config",
action="store_true",
help="Regenerate bbot.yml from current defaults (overwrites; backs up to .bak)",
)
misc.add_argument(
"--reset-secrets",
action="store_true",
help="Regenerate secrets.yml from current defaults (overwrites; backs up to .bak)",
)
misc.add_argument("--proxy", help="Use this proxy for all HTTP requests", metavar="HTTP_PROXY")
misc.add_argument(
"--no-proxy",
Expand Down
Loading