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
4 changes: 4 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@

# --- Operator-gated PR merge attestation (#1709) ---
/scripts/operator_gated_pr_guard.py @FabioLeitao
/scripts/native_package_release.py @FabioLeitao
/scripts/ci_dependabot_requirements_sync.sh @FabioLeitao
/scripts/homebrew_formula_bump.py @FabioLeitao
/.github/actions/ @FabioLeitao
/scripts/gate_trailer_attest.py @FabioLeitao
/scripts/__init__.py @FabioLeitao
/docs/adr/allowed_signers @FabioLeitao
Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ PyPI installs expose two **first-class** console entry points (same package, sam
- **`data-boar`** → **`main:main`** (CLI, one-shot scan, `--web` API server).
- **`data-boar-report`** → **`cli.reporter:main`** (executive Markdown from the local SQLite session — no live SQL connector; see [docs/USAGE.md](docs/USAGE.md) section 5, [pt-BR](docs/USAGE.pt_BR.md)).

## Claude Code workflow

`.github/workflows/claude.yml` is the source of the actor gate. A short constraint list:

- Events: issue comment, pull request review comment, pull request review, or an issue `opened` / `assigned`.
- The comment, review, or issue text contains `@claude`.
- `author_association` is `OWNER`, `MEMBER`, or `COLLABORATOR`.
- The GitHub user type is not `Bot`.

## Quick start (development)

1. **Clone and enter the repo**
Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.pt_BR.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ Instalações via PyPI expõem **dois** entry points de console de **mesmo níve
- **`data-boar`** → **`main:main`** (CLI, varredura *one-shot*, servidor API com `--web`).
- **`data-boar-report`** → **`cli.reporter:main`** (Markdown executivo a partir da sessão no SQLite local — sem conector SQL ao vivo; ver [docs/USAGE.pt_BR.md](docs/USAGE.pt_BR.md) *Resumo executivo*, [EN](docs/USAGE.md) seção 5).

## Workflow do Claude Code

`.github/workflows/claude.yml` é a fonte do gate de ator. Lista curta:

- Eventos: comentário em issue, comentário de review em pull request, review de pull request, ou issue `opened` / `assigned`.
- O texto do comentário, do review ou da issue contém `@claude`.
- `author_association` é `OWNER`, `MEMBER` ou `COLLABORATOR`.
- O tipo do usuário no GitHub não é `Bot`.

## Início rápido (desenvolvimento)

1. **Clone e entre no repositório**
Expand Down
72 changes: 63 additions & 9 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -615,14 +615,14 @@ def _get_config_yaml_for_display() -> str:
raw = _get_config_raw()
try:
data = yaml.safe_load(raw)
if isinstance(data, dict):
redacted = redact_config_for_display(data)
return yaml.dump(
redacted, default_flow_style=False, allow_unicode=True, sort_keys=False
)
if not isinstance(data, dict):
return "# config display unavailable\n"
redacted = redact_config_for_display(data)
return yaml.dump(
redacted, default_flow_style=False, allow_unicode=True, sort_keys=False
)
except Exception:
pass
return raw
return "# config display unavailable\n"


def _default_config_yaml() -> str:
Expand Down Expand Up @@ -669,7 +669,59 @@ def _save_config_yaml(yaml_content: str) -> None:
reset_license_guard_for_tests()


def _merge_and_save_config_yaml(submitted_yaml: str) -> None:
_POLICY_API_KEYS = (
"rbac",
"api_key",
"require_api_key",
"audit_logs",
"webauthn",
)


def _restore_policy_subtrees(merged: dict, current: dict) -> dict:
"""Keep security-policy subtrees from the file when the principal is not admin."""
import copy

current_api = current.get("api") if isinstance(current.get("api"), dict) else {}
merged_api = merged.get("api")
if not isinstance(merged_api, dict):
merged_api = {}
merged["api"] = merged_api
for key in _POLICY_API_KEYS:
if key in current_api:
merged_api[key] = copy.deepcopy(current_api[key])
else:
merged_api.pop(key, None)
for key in [
k for k in merged_api if isinstance(k, str) and k.startswith("trusted_")
]:
if key not in current_api:
merged_api.pop(key, None)
for key, value in current_api.items():
if isinstance(key, str) and key.startswith("trusted_"):
merged_api[key] = copy.deepcopy(value)
if "licensing" in current:
merged["licensing"] = copy.deepcopy(current["licensing"])
else:
merged.pop("licensing", None)
return merged


def _principal_lacks_admin(current_data: dict, request: Request) -> bool:
from api.rbac import resolve_effective_roles_for_request
from core.rbac_settings import rbac_enforcement_active

if not rbac_enforcement_active(current_data):
return False
roles = resolve_effective_roles_for_request(
request, current_data, _get_engine().db_manager
)
return "admin" not in (roles or [])


def _merge_and_save_config_yaml(
submitted_yaml: str, request: Request | None = None
) -> None:
"""Merge submitted YAML with current config so redacted/placeholder secret values do not overwrite real secrets, then save."""
from config.redact_config import merge_config_on_save

Expand All @@ -687,6 +739,8 @@ def _merge_and_save_config_yaml(submitted_yaml: str) -> None:
if not isinstance(submitted_data, dict):
raise ValueError("Config must be a YAML object")
merged = merge_config_on_save(submitted_data, current_data)
if request is not None and _principal_lacks_admin(current_data, request):
merged = _restore_policy_subtrees(merged, current_data)
merged_yaml = yaml.dump(
merged, default_flow_style=False, allow_unicode=True, sort_keys=False
)
Expand Down Expand Up @@ -2339,7 +2393,7 @@ async def config_save(request: Request, locale_slug: LocaleSlug):
context=_i18n_template_context(request, ls, tag, err_ctx),
)
try:
_merge_and_save_config_yaml(yaml_content)
_merge_and_save_config_yaml(yaml_content, request)
return RedirectResponse(url=f"/{ls}/config?saved=1", status_code=303)
except ValueError as e:
from core.validation import clean_error
Expand Down
23 changes: 15 additions & 8 deletions config/redact_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"private_key",
"credential",
"bearer",
"webhook",
}
)

Expand Down Expand Up @@ -57,16 +58,20 @@ def _is_sensitive_query_key(key: str) -> bool:
def _redact_url_like_string(value: str) -> str:
if "://" not in value:
return value
split = urlsplit(value)
if not split.scheme:
return value
try:
split = urlsplit(value)
if not split.scheme:
return value
port = split.port
except ValueError:
return REDACTED_PLACEHOLDER
out_netloc = split.netloc
if split.username is not None and split.password is not None:
username = quote(split.username, safe="")
host = split.hostname or ""
if ":" in host and not host.startswith("["):
host = f"[{host}]"
port_part = f":{split.port}" if split.port is not None else ""
port_part = f":{port}" if port is not None else ""
out_netloc = f"{username}:{_INLINE_SECRET_PLACEHOLDER}@{host}{port_part}"
pairs = parse_qsl(split.query, keep_blank_values=True)
if pairs:
Expand Down Expand Up @@ -166,7 +171,9 @@ def _walk_merge(
return current

if isinstance(submitted, dict) and isinstance(current, dict):
out = dict(current)
# Omitted keys are deletions. A secret key stays only when the
# submitted value is the placeholder or empty.
out: dict[Any, Any] = {}
for k, v in submitted.items():
sub_path = f"{path}.{k}" if path else k
if isinstance(k, str) and _is_secret_key(k):
Expand All @@ -182,18 +189,18 @@ def _walk_merge(
else:
out[k] = _walk_merge(
v,
current.get(k) if isinstance(current, dict) else None,
current.get(k) if k in current else None,
sub_path,
)
return out
if isinstance(submitted, list) and isinstance(current, list):
return [
_walk_merge(
submitted[i] if i < len(submitted) else None,
submitted[i],
current[i] if i < len(current) else None,
f"{path}[{i}]",
)
for i in range(max(len(submitted), len(current)))
for i in range(len(submitted))
]
return submitted if submitted is not None else current

Expand Down
3 changes: 3 additions & 0 deletions connectors/mongodb_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ def connect(self) -> None:
"serverSelectionTimeoutMS": connect_s * 1000,
"connectTimeoutMS": connect_s * 1000,
"socketTimeoutMS": read_s * 1000,
# Stay on the guarded seed. Replica-set discovery would dial peers
# the SSRF check never saw.
"directConnection": True,
}
if tls_enabled:
client_kwargs["tls"] = True
Expand Down
48 changes: 38 additions & 10 deletions core/findings_sink.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ def _sql_userinfo_prefix(cfg: dict[str, Any]) -> str:
return f"{user}:{password}@" if password else f"{user}@"


def _guard_sink_peer(host: str, port: int | None, cfg: dict[str, Any]) -> None:
def _guard_sink_peer(host: str, port: int | None, cfg: dict[str, Any]) -> list[str]:
"""SSRF-check the *parsed* peer (hostname + port), not the raw YAML string."""
from connectors.url_guard import (
resolve_and_validate_outbound_url,
Expand All @@ -250,20 +250,24 @@ def _guard_sink_peer(host: str, port: int | None, cfg: dict[str, Any]) -> None:
except ValueError:
candidate = bare
target = f"{candidate}:{port}" if port is not None else candidate
err, _ips = resolve_and_validate_outbound_url(
err, ips = resolve_and_validate_outbound_url(
target,
allow_private=target_allows_private(cfg),
label="findings_sink.host",
)
if err:
raise FindingsSinkError(err)
return [str(ip) for ip in ips]


def _guard_sql_host(cfg: dict[str, Any]) -> None:
"""Validate the peer SQLAlchemy/Mongo will actually connect to (#1816)."""
def _guard_sql_host(cfg: dict[str, Any]) -> list[str]:
"""Validate the peer SQLAlchemy/Mongo will actually connect to (#1816).

Returns the guard-accepted addresses so the dial uses those peers.
"""
kind = _canonical_type(cfg.get("type"))
if kind == "sqlite":
return
return []
if kind in _MONGO_TYPES:
from urllib.parse import urlparse

Expand All @@ -273,8 +277,7 @@ def _guard_sql_host(cfg: dict[str, Any]) -> None:
raise FindingsSinkError(
"findings_sink.host is required for SQL/Mongo sinks"
)
_guard_sink_peer(host, parsed.port, cfg)
return
return _guard_sink_peer(host, parsed.port, cfg)
from sqlalchemy.engine.url import make_url

parsed = make_url(_sqlalchemy_url(cfg))
Expand All @@ -286,7 +289,30 @@ def _guard_sql_host(cfg: dict[str, Any]) -> None:
host = parsed.host
if not host:
raise FindingsSinkError("findings_sink.host is required for SQL/Mongo sinks")
_guard_sink_peer(host, parsed.port, cfg)
return _guard_sink_peer(host, parsed.port, cfg)


def _open_sql_sink_engine(cfg: dict[str, Any], pin_ips: list[str]):
"""Open the SQL sink engine with the same peer pin the SQL connector uses."""
kind = _canonical_type(cfg.get("type"))
url = _sqlalchemy_url(cfg)
connect_args: dict[str, Any] = {}
dns_pin = None
if kind == "postgresql" and pin_ips:
from connectors.sql_connector import _apply_postgres_hostaddr_pin

connect_args = _apply_postgres_hostaddr_pin({}, pin_ips)
elif kind in ("mysql", "mariadb") and pin_ips:
from connectors.sql_connector import _install_mysql_host_resolution_pin

prefix = _SQL_URL_PREFIX.get("mysql" if kind == "mariadb" else kind, "")
dns_pin = _install_mysql_host_resolution_pin(url, pin_ips, drivername=prefix)
elif kind == "mssql" and pin_ips:
from connectors.sql_connector import _apply_mssql_tcp_peer_pin

url = _apply_mssql_tcp_peer_pin(url, pin_ips)
engine = create_engine(url, pool_pre_ping=True, connect_args=connect_args)
return engine, dns_pin


def _sqlalchemy_url(cfg: dict[str, Any]) -> str:
Expand Down Expand Up @@ -675,14 +701,14 @@ def push_session_to_sink(
if require_explicit_sample_ack and samples_yaml and not allow_sample_export:
raise SampleExportNotAcknowledged(SAMPLE_EXPORT_REFUSED)
include_sample = bool(samples_yaml and allow_sample_export)
_guard_sql_host(cfg)
pin_ips = _guard_sql_host(cfg)
meta = _session_meta(db_manager, sid)
rows = _finding_rows(db_manager, sid, include_sample=include_sample)
label = redacted_sink_label(cfg)
if kind in _MONGO_TYPES:
_push_mongo(cfg, meta, rows, on_conflict=_on_conflict(cfg))
Comment on lines +704 to 709

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Agentic Security Review
Severity: MEDIUM

pin_ips is captured from _guard_sql_host() (including Mongo) and then unused on the Mongo branch. _push_mongo() still builds MongoClient(uri) with no HostResolutionPin and no directConnection=True.

Impact: After the SSRF guard accepts the seed, pymongo can re-resolve DNS and follow replica-set topology to peers the guard never saw — the same class this PR pins on the SQL sink and the Mongo scan connector.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit 6c388d4. Configure here.

return label
engine = create_engine(_sqlalchemy_url(cfg), pool_pre_ping=True)
engine, dns_pin = _open_sql_sink_engine(cfg, pin_ips)
try:
if kind == "sqlite":
_ensure_sqlite_schema(engine, include_sample=include_sample)
Expand All @@ -697,6 +723,8 @@ def push_session_to_sink(
raise FindingsSinkError(str(exc)) from exc
finally:
engine.dispose()
if dns_pin is not None:
dns_pin.release()
return label


Expand Down
8 changes: 7 additions & 1 deletion scripts/gate_change_tripwire.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@
# so it never appears in a diff; its tracked template carries the policy instead.
#
# Any path under GATE_PATH_PREFIXES is also gated (whole .github/workflows/ tree).
GATE_PATH_PREFIXES: tuple[str, ...] = (".github/workflows/",)
GATE_PATH_PREFIXES: tuple[str, ...] = (
".github/workflows/",
".github/actions/",
)

GATE_FILES: frozenset[str] = frozenset(
{
Expand All @@ -46,6 +49,9 @@
"scripts/gate_change_tripwire.py",
"scripts/gate_trailer_attest.py",
"scripts/operator_gated_pr_guard.py",
"scripts/native_package_release.py",
"scripts/ci_dependabot_requirements_sync.sh",
"scripts/homebrew_formula_bump.py",
"scripts/__init__.py",
".pre-commit-config.yaml",
".github/CODEOWNERS",
Expand Down
Loading
Loading