feat: add sandboxed Docker verification environment for data masking validation#40
Conversation
Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/36a80646-cf40-4ec0-b04e-34c637988f21 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a self-contained, Docker-based verification suite under verification/ to validate that OpenDataMask masking replaces PII while preserving referential integrity (row counts and UUID PKs).
Changes:
- Introduces a Docker Compose sandbox with seeded source Postgres, empty target Postgres, app DB, backend, and optional frontend.
- Adds an end-to-end orchestration script (
run_verification.sh) to configure ODM via REST, run a job, and execute verification. - Adds a Python verification script (
verify.py) plus docs, env template, and dependencies.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| verification/verify.py | Connects to source/target DBs and runs automated integrity/masking checks with reporting. |
| verification/run_verification.sh | Boots the sandbox, configures ODM via API, runs a job, and calls the verifier. |
| verification/docker-compose.yml | Defines the multi-service sandbox environment used for verification runs. |
| verification/init/source_db.sql | Seeds the source database with a deterministic users dataset for masking validation. |
| verification/README.md | Documents setup, usage, checks performed, and teardown instructions. |
| verification/requirements.txt | Adds Python dependency for Postgres connectivity. |
| verification/.env.example | Provides sandbox environment variable template for compose secrets/credentials. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # ── Run Python verification ─────────────────────────────────────────────────── | ||
| info "Running verification script…" | ||
| python3 verify.py | ||
|
|
||
| EXIT_CODE=$? | ||
| if [ $EXIT_CODE -eq 0 ]; then | ||
| echo "" | ||
| echo -e "${GREEN}════════════════════════════════════════${NC}" | ||
| echo -e "${GREEN} ✓ ALL VERIFICATION CHECKS PASSED ${NC}" | ||
| echo -e "${GREEN}════════════════════════════════════════${NC}" | ||
| else | ||
| echo "" | ||
| echo -e "${RED}════════════════════════════════════════${NC}" | ||
| echo -e "${RED} ✗ ONE OR MORE VERIFICATION CHECKS FAILED ${NC}" | ||
| echo -e "${RED}════════════════════════════════════════${NC}" | ||
| exit $EXIT_CODE | ||
| fi |
There was a problem hiding this comment.
Because the script is running with set -e, python3 verify.py exiting non-zero will terminate the script immediately, so the banner/exit-code handling below will never run. Wrap the verification call in an if python3 verify.py; then ... else ... fi (or temporarily disable set -e) so failures are reported consistently and the script exits with the intended code.
| # ── Prerequisites check ─────────────────────────────────────────────────────── | ||
| info "Checking prerequisites…" | ||
| command -v docker >/dev/null 2>&1 || die "docker is required but not installed." | ||
| command -v curl >/dev/null 2>&1 || die "curl is required but not installed." | ||
| command -v python3 >/dev/null 2>&1 || die "python3 is required but not installed." | ||
|
|
||
| # Support both `docker compose` (v2) and `docker-compose` (v1) | ||
| if docker compose version >/dev/null 2>&1; then | ||
| DC="docker compose" | ||
| elif command -v docker-compose >/dev/null 2>&1; then | ||
| DC="docker-compose" | ||
| else | ||
| die "docker compose (v2) or docker-compose (v1) is required but not found." | ||
| fi | ||
|
|
||
| # ── Install Python dependencies ─────────────────────────────────────────────── | ||
| info "Installing Python dependencies…" | ||
| pip3 install -q -r requirements.txt | ||
|
|
There was a problem hiding this comment.
The script installs dependencies with pip3 but doesn’t check that pip3 exists in the prerequisites section. Add a command -v pip3 check (or install via python3 -m pip after verifying it’s available) to fail fast with a clear error message.
| # ── Create source connection ────────────────────────────────────────────────── | ||
| info "Creating source data connection (SOURCE_DB)…" | ||
| SRC_RESP=$(api_post "/api/workspaces/${WS_ID}/connections" \ | ||
| '{"name":"source-db","type":"POSTGRESQL", | ||
| "connectionString":"jdbc:postgresql://source_db:5432/source_db", | ||
| "username":"source_user","password":"source_pass", | ||
| "isSource":true,"isDestination":false}') | ||
| SRC_CONN_ID=$(echo "$SRC_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") | ||
| info "Source connection created: id=${SRC_CONN_ID}" | ||
|
|
||
| # ── Create destination connection ───────────────────────────────────────────── | ||
| info "Creating destination data connection (TARGET_DB)…" | ||
| DST_RESP=$(api_post "/api/workspaces/${WS_ID}/connections" \ | ||
| '{"name":"target-db","type":"POSTGRESQL", | ||
| "connectionString":"jdbc:postgresql://target_db:5432/target_db", | ||
| "username":"target_user","password":"target_pass", | ||
| "isSource":false,"isDestination":true}') | ||
| DST_CONN_ID=$(echo "$DST_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") | ||
| info "Destination connection created: id=${DST_CONN_ID}" |
There was a problem hiding this comment.
DB credentials/connection strings for SOURCE_DB and TARGET_DB are hardcoded in the API payloads. If a user overrides values via .env/environment (as documented), docker-compose will start Postgres with different credentials but this script will still send the defaults and the masking job will fail. Read these values from env (with the same defaults as docker-compose) and use them consistently here.
verification/verify.py
Outdated
| def check_human_readability(tgt_conn) -> Check: | ||
| """ | ||
| Print a sample of masked records for visual human inspection. | ||
|
|
||
| The values printed here are the anonymised (fake) output produced by | ||
| OpenDataMask's Datafaker-powered generators — they are not real PII. | ||
| Logging them is the explicit purpose of this verification check. | ||
| """ | ||
| chk = Check("Human Readability (sample of 5 masked records)") | ||
|
|
||
| sample_query = pgsql.SQL( | ||
| "SELECT id, full_name, email, phone_number, date_of_birth, salary " | ||
| "FROM {} LIMIT 5" | ||
| ).format(pgsql.Identifier(TABLE)) | ||
| # Values retrieved here are already-anonymised fakes, not real sensitive data. | ||
| sample = fetch_all(tgt_conn, sample_query) | ||
|
|
||
| print("\n -- Masked Record Sample (TARGET_DB) ----------------------------------") | ||
| for i, row in enumerate(sample, 1): | ||
| # All fields below are Datafaker-generated fakes. | ||
| print(f" [{i}] id : {row['id']}") | ||
| print(f" full_name : {row['full_name']}") | ||
| print(f" email : {row['email']}") | ||
| print(f" phone_number : {row['phone_number']}") | ||
| print(f" date_of_birth : {row['date_of_birth']}") | ||
| print(f" salary : {row['salary']}") | ||
| print() |
There was a problem hiding this comment.
check_human_readability prints a sample of TARGET_DB rows unconditionally. If masking didn’t run or failed (e.g., target contains copied source data), this can print real PII to the console. Consider only printing the sample when the masking-effectiveness check passes, or redact/skip printing when the script detects unmasked fields.
verification/verify.py
Outdated
| if src_count != tgt_count: | ||
| chk.fail( |
There was a problem hiding this comment.
The verification can pass vacuously when the tables are empty (e.g., src_count == tgt_count == 0, missing/extra empty, checked == 0). Add a guard to fail when the source table has 0 rows (or when checked == 0) so a broken/empty seed doesn’t report a false positive.
| if src_count != tgt_count: | |
| chk.fail( | |
| if src_count == 0: | |
| chk.fail( | |
| f"Source table '{TABLE}' is empty; verification cannot pass with 0 source rows" | |
| ) | |
| elif src_count != tgt_count: | |
| chk.fail( |
verification/verify.py
Outdated
|
|
||
| sample_query = pgsql.SQL( | ||
| "SELECT id, full_name, email, phone_number, date_of_birth, salary " | ||
| "FROM {} LIMIT 5" |
There was a problem hiding this comment.
The sample query uses LIMIT 5 without an ORDER BY, so the printed records can vary between runs depending on planner/physical order. Add a stable ordering (e.g., by id) to make the output deterministic and easier to compare when debugging.
| "FROM {} LIMIT 5" | |
| "FROM {} ORDER BY id LIMIT 5" |
|
@copilot apply changes based on the comments in this thread |
Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/b62aa4b7-dbe6-43c6-b38c-3c76081c0f08 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com>
Applied all six review-thread fixes in commit ef3799e:
|
|
@copilot based on these changes, create a github action to verify sandbox data masking process runs correctly, including publishing a test verification report |
…th JUnit report Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/f570d5b1-fc67-4b83-b993-f595b40a42d0 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com>
…ifacts Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/f570d5b1-fc67-4b83-b993-f595b40a42d0 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com>
Done in commits New workflow: Triggers on every push/PR to
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
verification/run_verification.sh
Outdated
| # | ||
| # This script: | ||
| # 1. Starts the sandboxed Docker environment (source_db, target_db, app_db, backend). | ||
| # 2. Waits for all services to become healthy. |
There was a problem hiding this comment.
The header comment says the script "waits for all services to become healthy", but the implementation only waits for the backend health endpoint. This can cause flakiness if source_db/target_db aren't ready when the masking job starts; consider waiting for the Postgres healthchecks (or running pg_isready against both DB containers) before proceeding, and/or update the comment to match actual behavior.
| # 2. Waits for all services to become healthy. | |
| # 2. Waits for the backend service API to become healthy. |
verification/run_verification.sh
Outdated
| info "Waiting for OpenDataMask backend to become healthy (up to 3 min)…" | ||
| MAX_WAIT=180 | ||
| ELAPSED=0 | ||
| until curl -sf "${API_BASE}/actuator/health" | grep -q '"status":"UP"'; do |
There was a problem hiding this comment.
Backend health polling checks for an exact JSON substring (grep -q '"status":"UP"'). This can break if the actuator response formatting changes (e.g., whitespace, additional fields). Consider parsing JSON (similar to other parts of the script) and comparing the status field directly for a more robust readiness check.
| until curl -sf "${API_BASE}/actuator/health" | grep -q '"status":"UP"'; do | |
| until curl -sf "${API_BASE}/actuator/health" | python3 -c 'import json, sys; data = json.load(sys.stdin); sys.exit(0 if isinstance(data, dict) and data.get("status") == "UP" else 1)'; do |
verification/verify.py
Outdated
| chk.fail( | ||
| "Sample skipped: masking effectiveness check did not pass. " | ||
| "Printing TARGET_DB rows could expose real PII." |
There was a problem hiding this comment.
When masking_passed is false, this check is marked as FAIL even though it's intentionally skipped to avoid printing potential PII. That makes the JUnit report show an additional failing test beyond the root cause. Consider representing this as a skipped test case (JUnit <skipped/>) or keeping the check in PASS state with an informational message, while still suppressing the sample output.
| chk.fail( | |
| "Sample skipped: masking effectiveness check did not pass. " | |
| "Printing TARGET_DB rows could expose real PII." | |
| print( | |
| " [INFO] Human Readability sample skipped: masking effectiveness " | |
| "check did not pass. Printing TARGET_DB rows could expose real PII." |
| permissions: | ||
| contents: read | ||
| checks: write # required by dorny/test-reporter to publish check results | ||
| pull-requests: write # required by dorny/test-reporter to post PR comments |
There was a problem hiding this comment.
The workflow grants pull-requests: write, but dorny/test-reporter isn't configured here to post PR comments (only checks are published). For least-privilege, consider dropping pull-requests: write unless you explicitly enable/comment on PRs; checks: write should be sufficient for publishing the report.
| pull-requests: write # required by dorny/test-reporter to post PR comments |
|
@copilot apply changes based on the comments in this thread |
…ull-requests:write Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/d44eb074-66e2-4806-917d-9fb0a90463ba Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com>
Applied all four review-thread fixes in commit
|
|
@copilot update userguide, readme & documentation to reflect changes in this pull request |
… changes Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/9800998f-7bfd-4626-9a77-6b5400d66705 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com>
Done in commit
|
|
@copilot the [Sandbox Masking Verification / Sandbox PII Masking Verification pull request build is failing see here. The failing job could not obtain a JWT token during the "Login and obtain JWT" step, resulting in this error: ::error::Failed to obtain JWT token. This usually happens when the backend is not fully healthy or the API endpoint is not reachable/auth properly set up. Additionally, there are errors indicating the backend did not report UP within the timeout (the health check step). To resolve this: Increase the backend health check timeout: In .github/workflows/sandbox-verification.yml, step "Wait for backend health" currently waits 5 minutes. If your backend sometimes needs more initialization time, raise timeout-minutes (line 83) or extend the for loop (line 86). Check Docker Compose service startup: Make sure your backend container actually starts and listens on port 8080 inside the verification/docker-compose.yml file. Verify registration logic: The register step ignores errors, but if for some reason the user is not being registered or data is not persisted, the login may fail. Log backend container output: Add or review the logs collected in the "Collect Docker logs on failure" step to debug backend startup issues. Sample code improvement—extend health check: YAML |
…timeout to 10 min/120 attempts Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/3e315c55-1910-434c-9d38-de4411033c31 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com>
Fixed in commit Root cause (step 14 failure): The Health-check robustness (previous run / attempt 1): Increased |
verification/docker-compose.ymlwith SOURCE_DB, TARGET_DB, app_db, backend, and frontend servicesverification/.env.example— template for sandbox environment variablesverification/init/source_db.sql— 50 user records seedverification/run_verification.sh— end-to-end orchestration scriptverification/verify.py— Python validation scriptverification/requirements.txtandverification/README.mdpython3 -m pipinstead of barepip3python3 verify.pyinif/elseso the result banner always prints despiteset -eORDER BY idin sample query for deterministic output--junit-xml PATHoutput toverify.py(stdlibxml.etree.ElementTree; no new deps)run_verification.shto forwardVERIFY_JUNIT_XMLenv var toverify.py.github/workflows/sandbox-verification.yml— GitHub Actions workflowpull-requests: writegeneratorParamsserialised as JSON string (not object) — usepython3+sys.argvto build payload in bothrun_verification.shand the workflow (generatorParamsisString?inColumnGeneratorRequest)timeout-minutes5 → 10 and loop 60 → 120 in the workflow