From 60b25318520d775e9cb005ef9695a2713d11ca61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?FabioLeit=C3=A3o?= Date: Tue, 20 Oct 2026 20:57:28 -0300 Subject: [PATCH 1/5] fix(connectors): dial findings-sink peers the guard already accepted The SQL sink kept the validated addresses and dropped them before create_engine. The MongoDB connector now stays on the guarded seed. Refs #2097 --- connectors/mongodb_connector.py | 3 ++ core/findings_sink.py | 48 +++++++++++++++---- tests/test_findings_sink_pin.py | 84 +++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 tests/test_findings_sink_pin.py diff --git a/connectors/mongodb_connector.py b/connectors/mongodb_connector.py index f75ae3f4..79d34194 100644 --- a/connectors/mongodb_connector.py +++ b/connectors/mongodb_connector.py @@ -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 diff --git a/core/findings_sink.py b/core/findings_sink.py index 2984ec95..fbaf4f3c 100644 --- a/core/findings_sink.py +++ b/core/findings_sink.py @@ -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, @@ -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 @@ -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)) @@ -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: @@ -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)) 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) @@ -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 diff --git a/tests/test_findings_sink_pin.py b/tests/test_findings_sink_pin.py new file mode 100644 index 00000000..7598d37f --- /dev/null +++ b/tests/test_findings_sink_pin.py @@ -0,0 +1,84 @@ +"""Findings sink must connect to the IP the SSRF guard already accepted (#2097).""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from sqlalchemy.exc import SQLAlchemyError + + +def test_sql_sink_connect_uses_validated_ip(tmp_path, monkeypatch) -> None: + from core.database import LocalDBManager + from core.findings_sink import push_session_to_sink + + db = tmp_path / "audit.db" + mgr = LocalDBManager(str(db)) + sid = "sink-pin-01" + mgr.create_session_record(sid) + monkeypatch.setattr( + "connectors.url_guard.resolve_and_validate_outbound_url", + lambda *_a, **_k: (None, ["203.0.113.10"]), + ) + monkeypatch.setattr("core.findings_sink._require_sink_tier", lambda *_a, **_k: None) + captured: dict = {} + + def _engine(url, **kwargs): + captured["url"] = str(url) + captured["kwargs"] = kwargs + engine = MagicMock() + engine.begin.side_effect = SQLAlchemyError("stop-after-capture") + return engine + + monkeypatch.setattr("core.findings_sink.create_engine", _engine) + try: + push_session_to_sink( + { + "licensing": {"effective_tier": "pro"}, + "findings_sink": { + "enabled": True, + "type": "postgresql", + "host": "sink.example.test", + "port": 5432, + "database": "findings", + "user": "sink", + "pass": "dummy", + }, + }, + mgr, + sid, + ) + except Exception: + pass + blob = captured["url"] + str(captured["kwargs"]) + assert "203.0.113.10" in blob + assert "sink.example.test" not in captured["url"] or "203.0.113.10" in blob + + +def test_mongodb_connector_uses_direct_connection(monkeypatch) -> None: + import connectors.mongodb_connector as mongo + + monkeypatch.setattr(mongo, "_MONGO_AVAILABLE", True) + captured: dict = {} + + def _client(uri, **kwargs): + captured["uri"] = uri + captured["kwargs"] = kwargs + client = MagicMock() + client.__getitem__.return_value = MagicMock() + return client + + monkeypatch.setattr(mongo, "MongoClient", _client) + monkeypatch.setattr( + "connectors.url_guard.resolve_and_validate_outbound_url", + lambda *_a, **_k: (None, ["203.0.113.10"]), + ) + connector = mongo.MongoDBConnector( + {"host": "seed.test", "port": 27017, "database": "app"}, + scanner=None, + db_manager=None, + ) + connector.connect() + try: + assert captured["kwargs"].get("directConnection") is True + finally: + connector.close() From 2ba042c14e61791c3f9e2e85f5e7e44e1e49f337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?FabioLeit=C3=A3o?= Date: Tue, 20 Oct 2026 20:58:20 -0300 Subject: [PATCH 2/5] fix(api): freeze policy subtrees and fail closed on config display A config_admin save no longer rewrites RBAC, the API key, audit logs, WebAuthn, trusted_* keys, or licensing unless the principal has admin. The POST still returns 303. A URL that cannot be redacted becomes the placeholder, omitted non-secret keys and shortened lists are deletions, and webhook URLs are redacted. Refs #2090 Refs #2093 --- api/routes.py | 72 ++++++++++++++--- config/redact_config.py | 23 ++++-- tests/test_config_admin_policy_boundary.py | 82 +++++++++++++++++++ tests/test_config_editor_redaction.py | 92 ++++++++++++++++++++++ 4 files changed, 252 insertions(+), 17 deletions(-) create mode 100644 tests/test_config_admin_policy_boundary.py create mode 100644 tests/test_config_editor_redaction.py diff --git a/api/routes.py b/api/routes.py index 87819eff..a91e6657 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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: @@ -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 @@ -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 ) @@ -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 diff --git a/config/redact_config.py b/config/redact_config.py index 1a3f1061..910dd14c 100644 --- a/config/redact_config.py +++ b/config/redact_config.py @@ -28,6 +28,7 @@ "private_key", "credential", "bearer", + "webhook", } ) @@ -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: @@ -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): @@ -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 diff --git a/tests/test_config_admin_policy_boundary.py b/tests/test_config_admin_policy_boundary.py new file mode 100644 index 00000000..5624ce71 --- /dev/null +++ b/tests/test_config_admin_policy_boundary.py @@ -0,0 +1,82 @@ +"""config_admin must not rewrite the security-policy subtrees (#2090).""" + +from __future__ import annotations + +import re +from pathlib import Path + +import yaml +from fastapi.testclient import TestClient + + +def test_config_admin_cannot_grant_itself_admin(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv( + "DATA_BOAR_WEBAUTHN_TOKEN_SECRET", "unit-test-webauthn-secret-min-16" + ) + log_dir = tmp_path / "logs" + log_dir.mkdir() + (log_dir / "audit_20261020.log").write_text("ok\n", encoding="utf-8") + cfg = tmp_path / "config.yaml" + db = tmp_path / "audit.db" + cfg.write_text( + f"""targets: [] +report: + output_dir: {tmp_path} +sqlite_path: {db} +licensing: + mode: enforced + effective_tier: pro +api: + port: 8088 + api_key: dummy-key + require_api_key: true + audit_logs: + enabled: true + directory: {log_dir} + rbac: + enabled: true + api_key_roles: + - config_admin +scan: + max_workers: 1 +""", + encoding="utf-8", + ) + import api.routes as routes + + prev_path = routes._config_path + prev_cfg = routes._config + prev_eng = routes._audit_engine + routes._config_path = str(cfg) + routes._config = None + routes._audit_engine = None + try: + client = TestClient(routes.app) + headers = {"X-API-Key": "dummy-key"} + before = client.get("/logs", headers=headers) + assert before.status_code == 403, before.text + page = client.get("/en/config", headers=headers) + assert page.status_code == 200, page.text + match = re.search(r'name="csrf_token" value="([^"]+)"', page.text) + assert match + edited = yaml.safe_load(cfg.read_text(encoding="utf-8")) + edited["api"]["rbac"]["api_key_roles"] = ["config_admin", "admin"] + saved = client.post( + "/en/config", + headers=headers, + data={ + "csrf_token": match.group(1), + "yaml": yaml.safe_dump(edited), + }, + follow_redirects=False, + ) + assert saved.status_code == 303, saved.text + after = client.get("/logs", headers=headers) + assert after.status_code == 403, after.text + finally: + routes._config_path = prev_path + routes._config = prev_cfg + routes._audit_engine = prev_eng + from core.licensing.guard import reset_license_guard_for_tests + + reset_license_guard_for_tests() diff --git a/tests/test_config_editor_redaction.py b/tests/test_config_editor_redaction.py new file mode 100644 index 00000000..4c8d6f35 --- /dev/null +++ b/tests/test_config_editor_redaction.py @@ -0,0 +1,92 @@ +"""Config editor redaction and merge (#2093).""" + +from __future__ import annotations + +from pathlib import Path + +from config.redact_config import ( + REDACTED_PLACEHOLDER, + merge_config_on_save, + redact_config_for_display, +) + + +def test_malformed_userinfo_url_does_not_reveal_password() -> None: + shown = redact_config_for_display({"note": "http://user:senha@host:portaruim/path"}) + blob = str(shown) + assert "senha" not in blob + assert shown["note"] == REDACTED_PLACEHOLDER + + +def test_malformed_url_does_not_reveal_secrets(tmp_path: Path) -> None: + import api.routes as routes + + cfg = tmp_path / "config.yaml" + cfg.write_text( + 'api:\n api_key: dummy-key-123\nnote: "http://[x"\n', + encoding="utf-8", + ) + prev = routes._config_path + routes._config_path = str(cfg) + try: + shown = routes._get_config_yaml_for_display() + finally: + routes._config_path = prev + assert "dummy-key-123" not in shown + + +def test_merge_drops_omitted_non_secret_keys_and_list_items() -> None: + shortened = merge_config_on_save( + {"api": {"trusted_proxy_cidrs": ["10.0.0.0/8"]}}, + {"api": {"trusted_proxy_cidrs": ["10.0.0.0/8", "192.0.2.0/24"]}}, + ) + assert shortened["api"]["trusted_proxy_cidrs"] == ["10.0.0.0/8"] + + roles = merge_config_on_save( + {"api": {"rbac": {"default_roles": ["dashboard", "scanner"]}}}, + { + "api": { + "rbac": { + "default_roles": [ + "dashboard", + "scanner", + "reports_reader", + "config_admin", + ] + } + } + }, + ) + assert roles["api"]["rbac"]["default_roles"] == ["dashboard", "scanner"] + + dropped = merge_config_on_save( + {"api": {"require_api_key": True}}, + {"api": {"require_api_key": True, "x_extra": 1}}, + ) + assert "x_extra" not in dropped["api"] + + targets = merge_config_on_save( + {"targets": [{"name": "b"}]}, + {"targets": [{"name": "a", "pass": "p1", "host": "h1"}, {"name": "b"}]}, + ) + assert targets["targets"] == [{"name": "b"}] + + +def test_webhook_urls_are_redacted_for_display() -> None: + shown = redact_config_for_display( + { + "notifications": { + "operator": { + "slack_webhook_url": "https://hooks.slack.com/services/T000/B000/XXXXXXXX", + "teams_webhook_url": "https://example.invalid/webhook/abc123", + "telegram_bot_token": "dummy-token", + } + } + } + ) + op = shown["notifications"]["operator"] + assert op["slack_webhook_url"] == REDACTED_PLACEHOLDER + assert op["teams_webhook_url"] == REDACTED_PLACEHOLDER + assert op["telegram_bot_token"] == REDACTED_PLACEHOLDER + assert "XXXXXXXX" not in str(shown) + assert "abc123" not in str(shown) From 6c388d43ce7d0c91da22b95fce1236794de6a7b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?FabioLeit=C3=A3o?= Date: Tue, 20 Oct 2026 20:59:09 -0300 Subject: [PATCH 3/5] docs: record who can trigger the Claude workflow CONTRIBUTING states the actor gate already enforced by .github/workflows/claude.yml. The workflow file is unchanged. Refs #2105 --- CONTRIBUTING.md | 9 +++++++++ CONTRIBUTING.pt_BR.md | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44e568ac..aa888b79 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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** diff --git a/CONTRIBUTING.pt_BR.md b/CONTRIBUTING.pt_BR.md index 1fa79cfc..97e2e7a3 100644 --- a/CONTRIBUTING.pt_BR.md +++ b/CONTRIBUTING.pt_BR.md @@ -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** From 74aef65c12dc7a819d0e45ab36415193b35912cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?FabioLeit=C3=A3o?= Date: Tue, 20 Oct 2026 21:57:21 -0300 Subject: [PATCH 4/5] ci: cover release helpers and composite actions in the gate tripwire The tripwire and CODEOWNERS now include the native package script, the Dependabot requirements sync, the Homebrew formula bump, and .github/actions/. Refs #2098 --- .github/CODEOWNERS | 4 ++++ scripts/gate_change_tripwire.py | 8 +++++++- tests/test_gate_change_tripwire.py | 4 ++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 47b3c21d..4dc90c67 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/scripts/gate_change_tripwire.py b/scripts/gate_change_tripwire.py index 7e006e6d..60750aaa 100644 --- a/scripts/gate_change_tripwire.py +++ b/scripts/gate_change_tripwire.py @@ -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( { @@ -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", diff --git a/tests/test_gate_change_tripwire.py b/tests/test_gate_change_tripwire.py index e42d0256..675b47fe 100644 --- a/tests/test_gate_change_tripwire.py +++ b/tests/test_gate_change_tripwire.py @@ -55,8 +55,12 @@ def test_gate_files_set_covers_core_surfaces() -> None: "scripts/gate_trailer_attest.py", "docs/adr/allowed_signers", "scripts/__init__.py", + "scripts/native_package_release.py", + "scripts/ci_dependabot_requirements_sync.sh", + "scripts/homebrew_formula_bump.py", ): assert t.is_gate_path(path) + assert t.is_gate_path(".github/actions/install-libmariadb-dev/action.yml") def test_any_github_workflows_yaml_is_gate_path() -> None: From f78cb97f3970259e813539d76a7860e94c96cfa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?FabioLeit=C3=A3o?= Date: Tue, 20 Oct 2026 22:55:59 -0300 Subject: [PATCH 5/5] Gate-Change-Approved-By: @FabioLeitao (#2115) | #2098 achado 2: release helpers e .github/actions/ no tripwire e no CODEOWNERS | 2026-10-21 ``` -----BEGIN SSH SIGNATURE----- U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgyEpOM79Y37xE8qOhlhghEfLh9P TzRdk6n8nzLfBNGqwAAAAEZmlsZQAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gtZWQyNTUx OQAAAEBfvJnw01HQm6aaS9Svja8VVIbF3gdr995Zn9n2ttB4LR+dpcX00xqaAvI5mLwU/B 5Odcjs5RzbxQ7g6OmECJcG -----END SSH SIGNATURE----- ```