This guide explains how to add a new data-source connector to Data Boar so that targets of a new type (e.g. Snowflake, a custom API, or another database) can be scanned from config.yaml and reported in the same Excel/heatmap flow.
Português (Brasil): ADDING_CONNECTORS.pt_BR.md
- Config: Each entry in
targetshas at leastname,type, and type-specific fields (e.g.host,database,base_url). - Registry: Connectors register a type (and optionally a driver for
type: database) incore.connector_registry. - Resolution: For each target,
connector_for_target(target)returns the connector class to use. Fortype: database, the driver (e.g.postgresql+psycopg2) is normalized to an engine name (e.g.postgresql) and looked up in the registry. - Engine:
AuditEngine._run_target()instantiates the connector with(target_config, scanner, db_manager)(plus optional kwargs for filesystem/shares), then callsconnector.run().
So a connector must:
- Implement a class with
run(self)that performs the scan. - Use
self.scanner(e.g.scan_column(column_name, sample_text)) for sensitivity detection. - Report results via
self.db_manager.save_finding(...)and failures viaself.db_manager.save_failure(...). - Optionally implement
connect()andclose()for connection lifecycle;run()should call them (e.g. connect at start, close in afinallyblock).
| Requirement | Description |
|---|---|
| Constructor | __init__(self, target_config, scanner, db_manager, **kwargs). For database/API connectors the engine only passes these three; for filesystem and share connectors it may also pass extensions, scan_sqlite_as_db, sample_limit. |
run() |
Entry point. Connect, discover/sample, call scanner.scan_column(name, sample), then db_manager.save_finding(...) or save_failure(...). Close resources when done. |
connect() / close() |
Optional but recommended. Use in run() so connections are released. |
| Findings | Use save_finding(source_type="database", ...) for DB-like sources; save_finding(source_type="filesystem", ...) for file/share; save_finding(source_type="application", ...) (or api/crm/saas) for REST/CRM — Application findings sheet. |
| Failures | Use save_failure(target_name, reason, details) on unreachable targets or errors. |
Add a new file under connectors/, e.g. connectors/snowflake_connector.py.
- Implement a class (e.g.
SnowflakeConnector) with: __init__(self, target_config, scanner, db_manager, sample_limit=5)(match the engine’s constructor call for database-like connectors).connect()– create client/engine fromtarget_config.close()– dispose engine/close client.run()– callconnect(), then discover/sample, runscanner.scan_column(...), calldb_manager.save_finding(...)orsave_failure(...), and in afinallyblock callclose().
At the bottom of your module (or behind an optional-import guard), call:
from core.connector_registry import register
register("snowflake", SnowflakeConnector, ["name", "type"])The third argument is the list of config keys that must be present (e.g. for validation or docs). The engine does not enforce it; it only uses it for documentation.
- For a database driver: register the engine name (e.g.
snowflake) so that targets withtype: databaseanddriver: snowflake+connectorresolve to your class (see “Mapping target type to connector” below). - For a non-database target (e.g. API, share): register the type string that will appear in
target.type(e.g.api,rest,sharepoint).
-
type: database: Resolution is inconnector_for_target()incore/connector_registry.py. The driver is normalized to an engine name (e.g.snowflake+connector→snowflake). If you registered"snowflake", no change is needed; the existing logic will pick it up. -
New top-level type (e.g.
type: snowflake): Add a branch inconnector_for_target():if t == "snowflake": try: return get_connector("snowflake") except KeyError: return None
Then in config you use
type: snowflake(and optionally still supporttype: database+driver: snowflake...by registeringsnowflakeas above).
If the connector needs a library that is not in the default dependencies (e.g. snowflake-connector-python), add an optional extra in pyproject.toml:
[project.optional-dependencies]
bigdata = ["snowflake-connector-python>=3.0"]Users install with:
uv pip install -e ".[bigdata]"In the connector module, guard the import and registration so the app does not fail when the dependency is missing:
try:
import snowflake.connector
_SNOWFLAKE_AVAILABLE = True
except ImportError:
_SNOWFLAKE_AVAILABLE = False
class SnowflakeConnector:
# ...
if _SNOWFLAKE_AVAILABLE:
register("snowflake", SnowflakeConnector, ["name", "type"])So that the connector is loaded and registers itself, import it in core/engine.py:
try:
import connectors.snowflake_connector # noqa: F401
except ImportError:
passUse a try/except if the connector is optional (optional dependency); otherwise a plain import is enough.
In the README (or this doc), add:
- Which packages to install (e.g.
.[bigdata]orpip install snowflake-connector-python). - The target shape in YAML:
type,name, and required/optional keys (e.g.account,user,password,database,schema,warehouse). - A minimal example snippet.
-
Database-like sources (tables and columns): use
save_finding(source_type="database", ...)and pass at least: -
target_name,server_ip(or host),schema_name,table_name,column_name,data_type -
sensitivity_level,pattern_detected,norm_tag,ml_confidence -
Other DB fields are optional but useful for reports.
-
Filesystem / share sources (local paths, SMB, NFS, WebDAV downloads): use
save_finding(source_type="filesystem", ...)and pass: -
target_name,path,file_name,data_type -
sensitivity_level,pattern_detected,norm_tag,ml_confidence -
Application / API / CRM / SaaS (REST, HubSpot, and similar; future ERP): use
save_finding(source_type="application", ...)(aliases:api,crm,saas) with the same path-like shape as filesystem (path+file_name), e.g. CRM object type + property name, orGET /users | email. These appear on the Excel sheet Application findings, not Filesystem findings.
The scanner returns a dict with at least sensitivity_level, pattern_detected, norm_tag, ml_confidence; pass those through to save_finding. Skip or do not report rows where sensitivity_level == "LOW" if you want to match existing behavior.
Below is a minimal pattern for a Snowflake connector that reuses the same “discover → sample → scan_column → save_finding” flow as the SQL connector. You can adapt it to your driver and config keys.
# connectors/snowflake_connector.py
"""Snowflake connector: optional, requires snowflake-connector-python. Use type: database, driver: snowflake."""
from typing import Any
from core.connector_registry import register
try:
import snowflake.connector
from sqlalchemy import create_engine
_SNOWFLAKE_AVAILABLE = True
except ImportError:
_SNOWFLAKE_AVAILABLE = False
snowflake = None
def _build_url(target: dict[str, Any]) -> str:
account = target.get("account", "")
user = target.get("user", "")
password = target.get("pass", target.get("password", ""))
database = target.get("database", "")
schema = target.get("schema", "PUBLIC")
warehouse = target.get("warehouse", "")
# Build SQLAlchemy-style URL or use connector params; here simplified.
return f"snowflake://{user}:{password}@{account}/{database}/{schema}?warehouse={warehouse}"
class SnowflakeConnector:
def __init__(self, target_config: dict[str, Any], scanner: Any, db_manager: Any, sample_limit: int = 5):
self.config = target_config
self.scanner = scanner
self.db_manager = db_manager
self.sample_limit = sample_limit
self.engine = None
self._connection = None
def connect(self) -> None:
if not _SNOWFLAKE_AVAILABLE:
raise RuntimeError("snowflake-connector-python required. Install with: pip install snowflake-connector-python")
url = _build_url(self.config)
self.engine = create_engine(url)
self._connection = self.engine.connect()
def close(self) -> None:
if self._connection:
try:
self._connection.close()
except Exception:
pass
self._connection = None
if self.engine:
self.engine.dispose()
self.engine = None
def discover(self) -> list[dict[str, Any]]:
"""Return list of {schema, table, columns: [{name, type}]}."""
from sqlalchemy import inspect
inspector = inspect(self.engine)
result = []
for schema in inspector.get_schema_names():
for table in inspector.get_table_names(schema=schema):
columns = inspector.get_columns(table, schema=schema)
result.append({
"schema": schema or "",
"table": table,
"columns": [{"name": c["name"], "type": str(c["type"])} for c in columns],
})
return result
def sample(self, schema: str, table: str, column_name: str) -> str:
"""Return sample values for the column as a single string (not stored)."""
from sqlalchemy import text
quoted = f'"{schema}"."{table}"."{column_name}"' if schema else f'"{table}"."{column_name}"'
q = text(f"SELECT {quoted} FROM {quoted.rpartition('.')[0]} LIMIT {self.sample_limit}")
try:
rows = self._connection.execute(q).fetchall()
return " ".join(str(r[0])[:200] for r in rows if r[0] is not None)
except Exception:
return ""
def run(self) -> None:
target_name = self.config.get("name", "snowflake")
try:
self.connect()
except Exception as e:
self.db_manager.save_failure(target_name, "unreachable", str(e))
return
try:
for item in self.discover():
schema = item["schema"]
table = item["table"]
for col in item["columns"]:
cname = col["name"]
ctype = col["type"]
sample = self.sample(schema, table, cname)
res = self.scanner.scan_column(cname, sample)
if res["sensitivity_level"] == "LOW":
continue
self.db_manager.save_finding(
source_type="database",
target_name=target_name,
server_ip=self.config.get("account", ""),
engine_details="snowflake",
schema_name=schema,
table_name=table,
column_name=cname,
data_type=ctype,
sensitivity_level=res["sensitivity_level"],
pattern_detected=res["pattern_detected"],
norm_tag=res.get("norm_tag", ""),
ml_confidence=res.get("ml_confidence", 0),
)
except Exception as e:
self.db_manager.save_failure(target_name, "error", str(e))
finally:
self.close()
if _SNOWFLAKE_AVAILABLE:
register("snowflake", SnowflakeConnector, ["name", "type", "account", "user", "database"])targets:
- name: "Warehouse_LGPD"
type: database
driver: snowflake
account: "xy12345.us-east-1"
user: "AUDIT_USER"
pass: "secret"
database: "COMPLIANCE_DB"
schema: "PUBLIC"
warehouse: "AUDIT_WH"Install optional dependency:
uv pip install -e ".[bigdata]"Then add in core/connector_registry.py a fallback for type: database so that driver: snowflake (or snowflake+connector) maps to the snowflake registry key (the existing loop over aliases does not include snowflake; you can add "snowflake" to the alias list or handle it in the same way as postgresql/mysql). And in core/engine.py:
try:
import connectors.snowflake_connector # noqa: F401
except ImportError:
passFor an API that returns JSON, you use filesystem findings and a single run() that fetches endpoints, flattens JSON, and runs scan_column on field names and sample values. See connectors/rest_connector.py for the full pattern:
- Constructor:
(target_config, scanner, db_manager, sample_limit=5). - Auth: Read
target_config["auth"](basic, bearer, oauth2_client, custom) and set headers or client auth. - Discovery: Either fixed
pathsin config or adiscover_urlthat returns a list of paths. - Per path: GET, parse JSON, flatten to
(key, value)pairs, callscanner.scan_column(key, value); if not LOW, calldb_manager.save_finding("filesystem", target_name=..., path=..., file_name=f"GET {path} | {key}", ...). - Failures:
save_failure(target_name, "error", message)on connection or parse errors. - Optional dependency: Guard with
try/import httpxand only register whenhttpxis available.
Config example:
targets:
- name: "Internal API"
type: api
base_url: "https://api.example.com"
auth:
type: bearer
token_from_env: "API_TOKEN"
paths:
- "/users"
- "/orders"
Managed SaaS CRM (Pro tier). Discovers all property names (including custom fields where CPF/CNPJ often hide), then samples objects with pagination and rate-limit backoff.
- Module:
connectors/hubspot_connector.py - Auth: Private App Token via env (never commit tokens). See setup below.
- SSRF: All outbound HTTP uses
connectors/url_guard.pyhost pinning (#1552) — same posture as the REST connector.
-
In HubSpot: Settings → Integrations → Private Apps. If the product UI has moved Private Apps under Legacy Apps, open that entry and continue — the credential type is still a Private app token (not a public OAuth app).
-
Create (or open) an app and choose Private (not Public).
-
Do not use a Service Key for Data Boar. Service keys are broader credentials without the granular CRM scopes this connector expects.
-
Under scopes, enable exactly these six read scopes (no write, no webhooks):
Scope Why crm.objects.contacts.readSample contact property values crm.objects.companies.readSample company property values crm.objects.deals.readSample deal property values crm.schemas.contacts.readDiscover contact property names (incl. custom) crm.schemas.companies.readDiscover company property names (incl. custom) crm.schemas.deals.readDiscover deal property names (incl. custom) -
Create the token and store it only in an environment variable / secret manager — never in committed YAML. Prefer an operator vault (e.g. Bitwarden) that injects
HUBSPOT_PRIVATE_APP_TOKENat process start; optional local file~/.config/databoar/hubspot.env(chmod 0600) is only a bridge — see OPERATOR_CREDENTIALS_FROM_ENV.md.
Least privilege: the connector is read-only and on-demand (no HubSpot webhooks).
crm.objects.*.read returns field values; crm.schemas.*.read is required for property
discovery. Without the schema scopes, custom fields (where CPF/CNPJ often live) are
never listed, so they are never sampled — a silent coverage gap.
Env var name must match exactly. The connector reads
HUBSPOT_PRIVATE_APP_TOKENby default (or the name you set intoken_from_env/auth.token_from_env). A similar but wrong name (for exampleHUBSPOT_ACCESS_TOKEN) does not raise a clear “wrong variable” error:connect()still runs, the API returns 401, and findings stay empty — the most common setup friction observed when wiring this connector. Confirm the exported name before debugging scopes.
Config example:
targets:
- name: crm-hubspot
type: hubspot
objects: [contacts, companies, deals]Optional keys: base_url (default https://api.hubapi.com; allowlisted hosts only —
api.hubapi.com or api-<region>.hubapi.com such as api-eu1.hubapi.com — #1607),
token_from_env
(override env var name), allow_private_networks (lab only).
HubSpot Forms (public embed / Forms API) and Data Boar’s CRM connector are
different surfaces. The connector only calls CRM objects (contacts, companies,
deals by default). It does not call the Forms submissions archive, email
workflows, or Gmail.
| Lead-capture path | Visible to type: hubspot? |
Why |
|---|---|---|
| Form fields mapped to Contact properties (standard or custom) | Yes (if scopes + property names match) | Connector discovers /crm/v3/properties/contacts and samples /crm/v3/objects/contacts |
| Form creates a Contact, then HubSpot emails you (workflow) | Contact yes; the email body no | Mail is outside CRM object sampling |
| Data only in Form submissions UI / export, never written to Contact properties | No | Wrong API surface for this connector |
Conversations / tickets / custom objects not listed in objects: |
No until you add that object type (and scopes) | Defaults are contacts/companies/deals only |
Operator checklist (portal + Data Boar):
-
In HubSpot, open a recent demo lead → Contact record. Confirm each form field exists as a contact property with a value (not only on the form submission timeline). Typical demo-request mapping:
Form intent Contact property name(internal)First / last name firstname,lastnameWork email emailCompany companyJob title cargo/jobtitle(must match the form’s HubSpot fieldname)Phone / WhatsApp phoneIndustry / vertical industry(create custom property if missing)Challenge + preferred slots often one message(or textarea) property -
Private App scopes must include both
crm.objects.contacts.readandcrm.schemas.contacts.read(see table above). Schema scope missing ⇒ custom fields never discovered ⇒ never sampled. -
Data Boar YAML:
type: hubspotwithobjects: [contacts](add companies/deals if needed). SetHUBSPOT_PRIVATE_APP_TOKENin the process environment — never in Git. Optional bridge:~/.config/databoar/hubspot.env+. ./scripts/databoar-env-load.sh hubspot. Vault-first (Bitwarden → same env name) is preferred when available — see OPERATOR_CREDENTIALS_FROM_ENV.md. -
Run a oneshot scan (not
--demo). In the report, findings appear as filesystem-style rows withpath=contactsandfile_name=<property>. Expect email / phone to surface as HIGH/MEDIUM when samples contain real values; free-textmessage/ names may be MEDIUM, LOW, or suggested review depending on detectors — absence of a finding is not proof the property was unread (empty sample or below threshold). -
Increase
sample_limiton the target (connector caps at 100) if the portal is large and the lead you care about is not among the first non-empty samples collected per property.
Customer-facing tip: document in your runbook which HubSpot properties the website form writes. If marketing adds a field on the form but forgets the matching Contact property (or the Private App schema scope), Data Boar will look “blind” even though HubSpot email notifications still work.
hubspot is a Pro+ connector (connector_hubspot in the licensing feature map).
Typical customer path: they open a paid support / engagement ticket; you help them
(1) wire Private App + env token, (2) confirm Contacts actually hold the fields, and
(3) run discovery under their YAML detection posture — not a HubSpot-only special
case.
| Layer | What the customer turns on | What you verify on HubSpot |
|---|---|---|
| Connect | type: hubspot, token, scopes, objects: |
401/scopes, property discovery, samples non-empty |
| Evaluate | Same global detection stack as SQL/FS: built-in patterns, regex_overrides, compliance samples / norm_tag, recommendation overrides, sensitivity thresholds |
Findings on path=contacts (etc.) carry the active norms — LGPD is the common BR baseline, but GDPR, CCPA/CPRA, HIPAA labels, custom norm_tag, and client overrides all apply when enabled in config |
| Out of band | Email workflows, Gmail, Forms-only archives | Out of connector scope — say so explicitly so “HubSpot emailed me” ≠ “Data Boar scanned it” |
Success for support is both: connection green and at least one finding (or an explicit empty-sample explanation) under the ruleset they paid you to interpret — not merely “API ping OK”.
Security: keep
base_urlat the default HubSpot API host unless you have a documented allowlist change. See issue discussion on host allowlisting for Bearer-token exfiltration risk whenbase_urlis attacker-controlled.
- New module under
connectors/<name>_connector.py. - Class with
run(), and optionallyconnect()/close(). - Uses
self.scanner.scan_column(name, sample)andself.db_manager.save_finding(...)/save_failure(...). -
register(<type>, <Class>, [required_config_keys])(and driver mapping inconnector_for_targetif new type or driver). - Optional dep in
pyproject.tomland import guard + conditional register. - Import in
core/engine.py(with try/except if optional). - README or this doc: install command, target config shape, example YAML.
Once these are in place, the new connector is used automatically for matching targets and appears in the same Excel and heatmap reports as existing connectors.
See also: Documentation index (all topics, both languages): README.md · README.pt_BR.md. TOPOLOGY.md (pt-BR) — application topology; TECH_GUIDE.md · TECH_GUIDE.pt_BR.md; TESTING.md (pt-BR); COMMIT_AND_PR.md (pt-BR); COMPLIANCE_FRAMEWORKS.md (pt-BR).