Skip to content

fix: security hardening and 2.1.0 release - #415

Merged
kevincostner17 merged 17 commits into
mainfrom
security/advisory-fixes
Sep 15, 2026
Merged

kevincostner17 merged 17 commits into
mainfrom
security/advisory-fixes

Conversation

@kevincostner17

@kevincostner17 kevincostner17 commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes for ten security advisories, applied on current main, plus the 2.1.0 release.

Advisory Fix Behaviour change
GHSA-2pcm-99rf-3fqq GPX/SDMX parsing now detects the document encoding (BOM, UTF-16/UTF-32 prefixes, XML declaration) and runs an expat pass that rejects DTD and entity declarations before reading. Previously a UTF-16 document bypassed the check and allowed entity expansion. DTDs and entity declarations are rejected in every encoding.
GHSA-h3vg-9xq5-7xh4 CSV formula sanitising now covers every level of multi-row headers, plus index labels and names. Previously, crafted header cells were written as live formulas. Header and index cells that start with formula characters are neutralised.
GHSA-q4wj-xrq5-gvww DuckDB no longer spills to the shared /tmp/freshdata_spill. Each run uses a private per-run directory (mode 0700) under the user cache dir, or FRESHDATA_SPILL_DIR, and removes it afterwards. Previously, other local users could read spilled rows. EngineConfig.temp_directory defaults to None, and an explicit directory is checked for ownership and permissions.
GHSA-2826-rpcg-97gg Baseline category labels were unkeyed, truncated SHA-1, so hashing a list of guesses could recover them. Labels are now omitted without a key, or HMAC-SHA256 with label_key / FRESHDATA_BASELINE_KEY. Schema is freshdata-baseline-v2; v1 files still load, with a warning.
GHSA-jq8x-9w3v-7j4g Token vault files were created with the process umask, often world-readable, which exposed the token-to-value mapping. They are now created as 0600 in the same step; SQLite journal and WAL files inherit that mode. Existing vaults readable by group or others trigger a warning.
GHSA-w58x-9xfc-pq9p Keyless tokenize, surrogate, fpe and the policy pseudonymize action fell back to public constants, so anyone could recompute outputs for guessed values. They now use a random per-call key. Emits EphemeralKeyWarning; keyless output is no longer stable across calls. Crypto FPE honours visible (#281).
GHSA-hhwr-8mxc-5j9r fd.learn(privacy='mask') treated only some PII types as sensitive, so card numbers, IBANs, IP addresses and health or licence identifiers were stored raw in profiles. Every detected type is now sensitive, unknown types fail closed, and card and bank column-name hints are added. freshdata profile audit flags raw cards and IBANs and exits 1. detect_pii scans categorical text on pandas 1.5 (#280).
GHSA-hfxp-fcx6-gj93 clean_enterprise reports held raw, pre-masking values of masked columns: cluster canonical values, variants and keys, validation samples, and coerced-cell originals and their warnings. These are now masked or redacted. [SENSITIVE:…] tokens were unkeyed SHA-256 and are now truncated HMAC-SHA256 under a per-process key. Report values for masked columns are tokens or <redacted>, and [SENSITIVE:…] tokens differ between runs.
GHSA-vfqx-wprp-wwg2 Copilot sample masking used a deny-list, so Arrow-backed string, dictionary and datetime columns reached model_context unmasked. It now uses an allow-list, and only numeric and boolean samples pass through. Non-numeric samples are hash-masked.
GHSA-239m-28fp-7fh2 Copilot masked by label, so integer, float and tuple column labels skipped masking. It now masks by column position, and sensitive_columns/must_mask match non-string labels. Unknown sensitive_columns and colliding labels raise, and masking fails closed. Adds analyze_dataset(mask_salt=...) for a reproducible model_context. The default is per-run, recorded in audit["mask_salt_source"] (#288).

Migration notes

  • EphemeralKeyWarning / key=: keyless tokenize / surrogate / fpe / pseudonymize output now changes between calls, including in the GDPR, HIPAA and FERPA packs. Pass key= / key_env= for stable, joinable pseudonyms. Keyless output from earlier releases can be reversed by trying candidate values, so re-pseudonymise it with a secret key.
  • Baseline v2 and label_key:
    • New baselines are written as freshdata-baseline-v2.
    • Without a key, categorical PSI compares by rank and can't detect categories swapping shares.
    • With label_key / FRESHDATA_BASELINE_KEY, compare using the same key. A missing or different key skips categorical PSI and reports drift.categorical_drift_skipped.
    • v1 baselines still load with a warning; rebuild them and delete the old files.
  • temp_directory is None: spill goes to a private per-run directory. An explicit temp_directory raises PermissionError if it isn't owned by the user, or if it is group- or other-writable without the sticky bit.
  • Masking changes:
    • clean_enterprise report objects carry tokens or <redacted> for masked columns. Cluster mapping is emptied and ClusterResult.redacted is set.
    • [SENSITIVE:…] tokens are per-process.
    • Copilot model_context and its fingerprint are per-run unless mask_salt is given.
    • Unknown sensitive_columns and labels that collide as strings raise.
  • profile audit exit code: freshdata profile audit now exits 1 when a profile that claims no raw values contains checksum-valid card numbers or IBANs; it used to exit 0. Re-learn the affected profiles.

Verification

  • ruff check . and mypy (src/freshdata): clean.
  • Full test lanes (-m "not online and not large"): 6255 passed on Python 3.12, 6226 passed on Python 3.9.
  • New security tests, run 3 times on 3.9 and 3.12: 301/301 every run.
  • make truthbench-pr and make truthbench-release (pandas, polars, duckdb; repeats 2): PASS, 48/48 gates, no baseline changes.
  • uv lock --check, version consistency (pyproject == __version__ == 2.1.0), sdist and wheel build, and twine check: all pass.

Closes #280
Closes #281
Closes #288

open_safe_xml_binary looked for "<!doctype"/"<!entity" in the raw,
lowercased bytes. In UTF-16 and UTF-32 documents those markers contain NUL
bytes, so the search never matched and ElementTree went on to expand
internal entities in GPX and SDMX input.

The guard now detects the document encoding (byte-order mark, UTF-16/UTF-32
prefix, or the XML declaration, following XML 1.0 Appendix F; EBCDIC is
refused) and scans the decoded text as well as the raw bytes. It then runs
an expat pass, configured as ElementTree configures it, whose DOCTYPE and
entity-declaration handlers raise, so a declaration is rejected in any
encoding expat reads. Parse errors in that pass are left for ElementTree to
report. UTF-16 documents without a DTD still parse.
…st formulas

sanitize_csv_formulas rebuilt the columns with _formula_guard applied to each
label, but _formula_guard only handles str. With a multi-row header
(read_csv_kwargs={"header": [0, 1]}) the labels are tuples, so every header
cell was written unchanged, including cells such as =HYPERLINK(...) taken
from the input. The rebuild also dropped columns.names, and with
to_csv_kwargs={"index": True} index labels and names were written unguarded.

The sanitizer now guards each level of MultiIndex columns and index, flat
string index labels, and column and index names. Non-string labels are left
as they are, and an axis that needs no change keeps its original type.
Header aliases passed to the writer are caller-supplied and are documented as
not guarded.
…reshdata_spill

EngineConfig.temp_directory defaulted to the fixed path /tmp/freshdata_spill,
which the DuckDB engine created with os.makedirs(exist_ok=True) and handed to
DuckDB without checking its owner or mode. Spill files hold rows of the data
being cleaned and are written with the process umask, so on a shared host
other local users could read them, a directory another user created first was
accepted as-is, and concurrent runs collided on DuckDB's fixed file names.

temp_directory now defaults to None. The new execution/_spill.py resolves a
base directory (an explicit temp_directory, else $FRESHDATA_SPILL_DIR, else
the per-user cache directory, falling back to the system temp directory only
when that is not writable), creates it with mode 0700, and refuses a base
that is not owned by the current user or is group/other-writable without the
sticky bit. Each run spills into its own mkdtemp directory under that base,
which is removed after the connection closes, or when a returned
output_format="duckdb" relation is released.

The test suite points FRESHDATA_SPILL_DIR at a pytest temp directory for the
whole session so no test writes to the real user cache.
Baseline categorical frequencies were stored under unkeyed, truncated SHA-1
labels, so anyone holding a baseline JSON could recover low-entropy labels
(diagnoses, countries, products) and their exact shares by hashing a guess
list.

- build_baseline(label_key=...) / $FRESHDATA_BASELINE_KEY: labels become
  "k:" + HMAC-SHA256 (domain-separated, 128 bits); only a key identifier is
  stored (label_mode="hmac-sha256").
- Without a key the baseline is label-free (label_mode="rank"): a descending
  frequency profile, compared by rank for categorical PSI.
- compare_to_baseline/monitor_contract take label_key; a keyed baseline with a
  missing or different key skips categorical PSI and reports a
  drift.categorical_drift_skipped warning instead of raising.
- In-process baselines (raw-frame compare_to_baseline, clean_enterprise inline
  baseline) use a random per-call key.
- Schema bumps to freshdata-baseline-v2; v1 baselines still load, compare via
  the legacy path and warn that they should be rebuilt.
- Threat model section 9 documents persisted baselines.
JsonTokenVault and SqliteTokenVault store the plaintext token-to-value
mapping, but created their files with the process umask, so under the
common 022 umask any local user could read them.

- JsonTokenVault opens its file through os.open(O_RDWR | O_CREAT |
  O_APPEND, 0o600), so a new vault is owner-only from the moment it is
  created. The in-place locked rewrite design is unchanged.
- SqliteTokenVault pre-creates the database file with mode 0600 before
  sqlite3.connect; SQLite gives journal, WAL and SHM files the same mode.
- A missing parent directory is created with mode 0700.
- An existing group/other-accessible vault file is used as is and
  triggers a UserWarning once per instance; its mode is never changed.
- Docstrings and threat-model section 8 describe the file modes.
…e and pseudonymize

Without a key, tokenize fell back to HMAC("freshdata-default-token-salt",
rule name), and surrogate, keyless fpe and the policy pseudonymize action
(the GDPR pack default) seeded their HMAC with "freshdata-surrogate". Both
constants are in the public source, so anyone holding the output could
recompute it for guessed values and recover SSNs, phone numbers and other
low-entropy identifiers.

- anonymize: a rule without a key (and not reversible) derives its key
  from a random secret generated once per call. Output is consistent
  within the call but differs across calls. One EphemeralKeyWarning names
  the rules, and report.metadata["ephemeral_key_rules"] lists them.
  Reversible keyless tokenize/fpe still raises.
- apply_privacy_policy: keyless pseudonymize uses one random key per call,
  with the same warning and metadata. Keyless tokenize still raises.
- _surrogate_value and _mask_one have no keyless path; the constants are
  gone. EphemeralKeyWarning is exported from freshdata.enterprise.
- Crypto FPE honours visible: only the digits before the last visible
  characters are encrypted, and a head without digits falls back to the
  surrogate (#281, part 3).
- Docs: MaskingRule strategies and keys, threat-model section 6, pack
  comments, and migration recipes in docs/compliance.md. Tests and the
  example that relied on keyless determinism now pass a key.
fd.learn(privacy='mask') mapped only a handful of detect_pii entity types to
sensitive profile types and silently dropped the rest, so card numbers, IBANs
and IP addresses found by the scanner were written into saved profiles as raw
value-map literals and examples.

- Map CREDIT_CARD, IBAN, IP_ADDRESS, MRN, PATIENT_ID, INSURANCE_ID,
  DRIVER_LICENSE, ZIP_CODE, GEO_LOCATION and ICD_CODE; any other reported type
  fails closed to free_text. DATE_OF_BIRTH only counts with a dob/birth column
  name (new date_of_birth type).
- Token-aware column-name hints for card, bank-account, IP and date-of-birth
  columns (pan/acct must be whole words, so company_name stays unmasked).
- freshdata profile audit re-scans stored literals and exits 1 when a profile
  that claims no raw values holds checksum-valid card numbers or IBANs.
- #280: add is_text_dtype() and use it in detect_pii and detection-driven
  anonymize, so categorical (and Arrow dictionary) text columns are scanned on
  pandas 1.5 as on pandas 2.
…ports

Clustering, semantic validation and core cleaning run before masking, so
their reports held pre-masking values: cluster canonical/variant/key values
and mappings, validation invalid_samples, and clean_report.coerced_cells (plus
the coercion warnings quoting them). to_dict()/to_json() then published raw
values of the columns the caller masked.

For every column a masking rule selects, or that PII detection changed, the
EnterpriseResult now holds redacted report objects:
- ClusterResult.redacted_copy(): canonical/variants go through the column's
  rule (hash/redact/partial/regex_scrub reproduce the data token;
  tokenize/fpe/surrogate/drop/detection become "<redacted>"), key becomes
  "<redacted>", mapping is emptied, counts kept; new redacted field.
- Validation invalid_samples are masked the same way.
- coerced_cells originals and the matching warning examples are masked.
mask_sensitive_value() made [SENSITIVE:xxxxxxxx] tokens as an unkeyed
sha256(repr(v))[:8]. Reports use these tokens in place of values from
declared sensitive_columns. Low-entropy values (SSNs, phone numbers, dates
of birth, small categories) could be recovered by hashing a guess list and
matching the tokens in CleanReport warnings, coerced_cells, semantic action
text and metadata, or validate_fields normalized_cells.

The token is now a truncated HMAC-SHA256 of repr(v). The key comes from
secrets.token_bytes(32), is made once per process on first use (behind a
lock), and is never persisted. There is no constant fallback. Token prefix
and length are unchanged, and within a run the same value still gives the
same token, so records correlate inside one report. Tokens differ between
processes.

No caller needs tokens to match across processes. The masked memory_key
and value_signature are never replayed against raw values. Repair-plan
params, which feed decisions_hash, hold the raw proposal, not the token.
TruthBench repeats run in one process. No golden files or tests pin a
digest. So there is no stable-key parameter.
In privacy="mask_pii_before_reasoning" the copilot decided which sample
columns to hash-mask with a deny-list that recognised only object,
StringDtype and CategoricalDtype as string-like. pd.ArrowDtype string,
large_string and dictionary columns (the output of pyarrow-backed readers)
were not matched, so their raw values went into model_context, the provider
prompt, to_json() and the HTML render. Datetime, timedelta and period
values were sent raw as well.

Replace the deny-list with an allow-list: only numeric and boolean dtypes
(numpy, nullable and Arrow-backed, checked through pyarrow on pandas < 2)
pass through raw. Every other column, including dtypes the copilot does not
recognise, is hash-masked. allow_unmasked_columns still opts a column out,
and declared or detected PII columns are always masked.
analyze_dataset matched masking rules and the declared mask set by
str(label). A must_mask or sensitive_columns entry whose column label was
not a str (an int from read_csv(header=None), a float, a tuple) never
matched, so the declared column was dropped from the mask set and its raw
values reached model_context and the provider prompt, while the audit still
listed it as masked. Default-mode masking of int labels only worked by
accident of snake_case name matching.

Sample masking now runs on a copy whose columns are renamed __c0, __c1, ...
Every mask-set entry is resolved to a position by its real label or
str(label), and the masked rows are mapped back to str(label) keys.

- Labels that collide once converted to str (0 and "0") raise ValueError.
- Unknown sensitive_columns raise ValueError, like allow_unmasked_columns.
- Masking fails closed: every non-missing value of a selected column must
  be a 16-hex hash token, or RuntimeError is raised with no values in the
  message. The check runs before free-text detection, which now scans only
  the unmasked columns and so can no longer rewrite a token.
Copilot sample masking built hash rules without a salt, so each rule got a
random salt and the masked sample rows and audit["model_context_sha256"]
changed on every run, contrary to the documented reproducibility (#288).

analyze_dataset(mask_salt=...) derives each column's hash salt as
HMAC-SHA256(mask_salt, "copilot-col:<position>"). The same salt reproduces
model_context and its fingerprint, and equal values in different columns
get different tokens. The default (None) stays a random per-run key. The
salt is never written to the report; audit["mask_salt_source"] records
"caller" or "per-run-random". An empty or non-str salt raises.

The TruthBench copilot adapter pins mask_salt so its sinks are identical
across runs and repeats. Unknown-column checks move into a small helper.
- ai-copilot.md: non-str labels, sensitive_columns, fail-closed masking,
  per-run sample tokens and mask_salt; reproducibility claims now say the
  findings, plan and code are stable and model_context only with mask_salt.
- threat-model.md section 3: positional masking, label collisions, unknown
  sensitive_columns, fail-closed check, per-run fingerprint; replace the
  stale CLAIM_REGISTRY reference with the tests that enforce the claim.
- threat-model.md section 6: replace the copilot sentences, which described
  a deterministic path that no longer exists, with the per-run key and
  mask_salt behaviour.
- trust-claims.md: the copilot claim is no longer in the CLAIM_REGISTRY;
  move it to the other claims table with its tests, and add mask_salt.
- A canary whose digits sit in one run inside a hex token still flags;
  the same digits split by letters inside a long leaf do not.
- A hash token carrying canary digits is flagged at $.rendered.html on
  every run.
- With the pinned mask_salt the copilot adapter renders identical HTML,
  prompt and model_context across seeded process randomness, with no leak
  (3 seeds in the default lane, 25 seeds on every domain under -m large).
- An int/float/tuple label and object/string/Arrow/categorical dtype sweep
  finds no canary in rendered.html, the prompt or model_context.
…tion

Integration fix. The report redaction added for masked columns resolves each
masking rule against the report's columns only (cluster, validation and
coercion reports) to find which report entries to mask. main's #406 made
_resolve_columns raise for listed columns that match nothing when the rule is
strict, and CLI --mask rules are strict. A masked column that appears in no
report (for example "email" without clustering hits) therefore raised
"specifies column(s) not found in dataframe" from clean_enterprise, failing
tests/test_enterprise_cli.py on the integrated branch.

Pass strict=False for this subset lookup, as privacy.py already does for its
duplicated-label lookup. The masking stage still enforces strict against the
real frame. Adds regression tests for both sides.
Bump version 2.0.0 -> 2.1.0 and finalize the changelog for release.
@coderabbitai

coderabbitai Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 09925fc0-52f5-497a-87f0-80314f1c754f


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

FreshData benchmark report — performance

  • freshdata: ?
  • python: ?
  • platform: ?
fixture n_rows n_cols p50 s p95 s peak MB repair % false-repair % preserve % trust monotonic export %

Authored-code reduction (Metric 6)

@kevincostner17
kevincostner17 merged commit 6cfe66a into main Sep 15, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant