Release-gate linter that checks Luciq-SDK apps for PII-masking gaps (iOS + Android).
Project description
Luciq PII Masking Linter
A release gate that statically scans a Luciq-SDK app for PII-masking gaps across the surfaces the SDK protects — screenshots, Session Replay, and network logs — so a forgotten mask can't ship to production.
- Deterministic & CI-friendly — same input, same result; one flag decides whether it blocks.
- Zero dependencies — pure Python 3.8+ standard library. Nothing to
pip installbut the tool itself. - No config duplication — reads your masking setup straight from the source. The only inputs it can't infer (compliance level, custom keywords) live in one small file.
- Platforms today: iOS (Swift) and Android (Kotlin/Java). New platforms are added as adapters.
Table of contents
- Installation
- Quick start
- Command-line reference
- Configuration (
luciq.yml) - Compliance levels
- Skipping a field
- What it checks
- Output formats
- CI integration
- IDE integration
- Exit codes
- Development
- The honest limit
Installation
The linter is a single self-contained module. Pick whichever fits your setup.
Option 1 — install the command (recommended)
Installs the luciq-masking-linter console command onto your PATH.
# from a checkout of this repo (run from the repo root)
pip install .
# or isolated in its own environment (no global pollution)
pipx install .
Once it's published to PyPI, drop the checkout and just pip install luciq-masking-linter.
Verify:
luciq-masking-linter --help
Option 2 — no install (run the script directly)
Because there are no third-party dependencies, you can run the module as-is with any Python 3.8+:
python3 luciq_masking_linter.py --help
Throughout this README,
luciq-masking-linter <args>andpython3 luciq_masking_linter.py <args>are interchangeable.
Requirements
- Python 3.8 or newer (standard library only — no
requirements.txt). PyYAMLis optional: if present it's used to parseluciq.yml; if not, a built-in minimal YAML reader handles the small subset the linter needs.
Quick start
Point it at your app's root. With no path it scans the current directory.
# Report gaps without blocking (good for local runs and PRs)
luciq-masking-linter /path/to/app --mode warn
# Block on failures (good for release branches / the gate)
luciq-masking-linter /path/to/app --mode enforce
Example output:
Luciq PII Masking Linter
platforms : ios
compliance: pci mode: enforce
[FAIL] unmasked-field PaymentView.swift:24 — card field is not masked (no private
marker, not covered by auto-mask, no waiver). To fix: mask it: `.luciq_privateView()` …
[warn] unmasked-field ProfileView.swift:31 — phone field is not masked …
Summary: 1 fail, 1 warn
Verdict: BLOCK release
--mode is the only behavior switch and it changes consequences, not findings:
the same gaps are reported either way; warn always exits 0, enforce exits 1 when
there's a hard failure. The linter knows nothing about git branches — your CI decides
which mode to run where (see CI integration).
Command-line reference
luciq-masking-linter [path] [--compliance LEVEL] [--mode {warn,enforce}]
[--format {text,xcode,github,sarif}] [--exclude GLOB ...]
| Option | Default | Description |
|---|---|---|
path |
. |
Project root to scan. |
--mode {warn,enforce} |
warn |
warn reports only (exit 0); enforce blocks on hard failures (exit 1). |
--compliance LEVEL |
none |
none · soc2 · pci · gdpr · hipaa. Overrides luciq.yml. |
--format {text,xcode,github,sarif} |
text |
Output renderer — see Output formats. |
--exclude GLOB |
— | Path glob or directory name to skip. Repeatable; merged with exclude: in luciq.yml. |
-h, --help |
— | Show usage. |
Examples:
# HIPAA gate, skipping generated code and test fixtures
luciq-masking-linter . --compliance hipaa --mode enforce \
--exclude 'app/generated/*' --exclude Tests
# Emit SARIF for GitHub Code Scanning
luciq-masking-linter . --format sarif > luciq.sarif
Configuration (luciq.yml)
The linter reads your masking setup directly from the code and never duplicates it.
The only two things it can't infer go in the pii_masking section of a luciq.yml
(or luciq.yaml) at your project root:
pii_masking:
# One of: none | soc2 | pci | gdpr | hipaa
compliance: none
# Custom PII keyword stems grouped by family. Each stem regex-matches any field
# name (case- and separator-insensitive), so `taxId` also catches `taxIdNumber`,
# `userTaxId`, `tax_id`, … Built-in families (email, card, ssn, phone, …) need no
# entry — only your app-specific naming does.
keywords:
ssn: [fiscalCode, taxId]
health: [mrnCode, diagnosisRef]
member: [memberRef] # a custom family with no built-in equivalent
# Optional: paths to skip (also available via --exclude). A pattern matches a whole
# relative path or any single path segment.
exclude:
- app/generated/*
- Tests
A ready-to-copy template lives in luciq.example.yml.
Keyword matching is case- and separator-insensitive. A stem is split into words
(on _ - . /, whitespace, and camelCase humps) and rejoined so the separators don't
matter — one entry covers every common spelling:
| Stem | Also catches | Doesn't catch |
|---|---|---|
membershipNo |
membership_no, membership-no, MEMBERSHIPNO, userMembershipNoField |
membershipNumber (different word) |
So you don't need to list membershipNo and membership_no separately. Keep stems
short (e.g. membership) to widen recall, or more specific to narrow it.
Compliance resolution order (first wins):
--compliance → LUCIQ_COMPLIANCE env var → luciq.yml → none.
Compliance levels
The compliance dial doesn't add new checks — it changes which findings block vs merely warn, and which auto-mask types are required.
| Level | Hard-fails on | Extra requirements |
|---|---|---|
none / soc2 |
card, SSN, credentials | — |
pci |
card, SSN, credentials | card-data waivers are refused |
gdpr |
+ email, phone, name, address, DOB, any text input | consent gate required |
hipaa |
all PII families | MEDIA required in auto-mask; consent gate required |
Field candidacy is name-driven — a TextField/EditText is flagged only when its
name matches a PII keyword (built-in or custom). A generically-named input
(TextField("Search", text: $query)) is not flagged under none/soc2/pci.
The exception is gdpr and hipaa, where masking all user input is mandatory: on
those two levels every text input is a candidate regardless of name (a hard fail
unless masked or waived). Add app-specific names to pii_masking.keywords to extend
coverage on the other levels.
See docs/keyword-families.md for the full family matrix.
Skipping a field
If a flagged field isn't really PII, add a comment to skip it:
TextField("Saved card", text: $savedCard) // luciq-mask-ignore
- Add
: reasonto leave a note:// luciq-mask-ignore: masked upstream. - To skip a whole file, put
// luciq-mask-ignore-fileanywhere in it. - Card fields can't be skipped under
--compliance pci.
What it checks
Each finding carries a self-describing check ID:
| Check ID | Layer | Catches |
|---|---|---|
auto-mask-config |
Screenshots / Session Replay | auto-mask not configured, set to MASK_NOTHING, or missing MEDIA when required |
unmasked-field |
Screenshots / Session Replay | a field whose name matches a PII keyword with no private marker, no auto-mask coverage, and no waiver (under GDPR/HIPAA, every text input counts regardless of name) |
network-disabled |
Network logs | network auto-masking explicitly disabled |
network-sdk-version |
Network logs | SDK older than 14.2.0 (network masking not on by default) |
consent-gate |
Defense-in-depth | Session Replay enabled without a consent gate (when compliance requires) |
flag-secure |
Defense-in-depth | ignoreFlagSecure(true) overriding FLAG_SECURE |
Full check list and architecture: docs/spec.md.
Output formats
--format |
Use it for |
|---|---|
text (default) |
Human-readable console report. |
github |
GitHub Actions annotations — findings appear inline on the PR diff. |
xcode |
file:line: warning/error: lines parsed by an Xcode Run Script phase. |
sarif |
SARIF 2.1.0 for GitHub Code Scanning and security dashboards (check IDs become rule IDs). |
In xcode/github, a finding renders as an error only when it would actually block
(a hard failure under --mode enforce); otherwise it renders as a warning.
CI integration
The linter is CI-agnostic — it's just a command that exits 0 (pass) or 1
(blocked). Any CI works the same way: install it, then run luciq-masking-linter --mode warn where you want a report and --mode enforce where you want a gate. The
GitHub Actions example below is the most detailed; the others show the identical idea
in other systems.
GitHub Actions
Let CI own the branch decision — the linter just obeys --mode:
env:
LUCIQ_COMPLIANCE: hipaa
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.x' }
- run: pip install luciq-masking-linter
# Pull requests: annotate inline, never block.
- name: PII masking (warn)
if: github.event_name == 'pull_request'
run: luciq-masking-linter . --format github --mode warn
# Release branch: block the build on failures.
- name: PII masking (gate)
if: github.ref == 'refs/heads/main'
run: luciq-masking-linter . --mode enforce
Prefer GitHub Code Scanning (inline annotations on the PR diff)? Emit SARIF and
upload it. Generate SARIF in its own step and upload with if: always() so the
findings still surface even when the enforce gate above fails the job:
permissions:
security-events: write # required to upload SARIF
steps:
- name: Scan (SARIF)
run: luciq-masking-linter . --format sarif > luciq.sarif
continue-on-error: true # SARIF generation must never abort the upload
- name: Upload SARIF
if: always() # upload even if the gate step failed
uses: github/codeql-action/upload-sarif@v3
with: { sarif_file: luciq.sarif }
A complete, ready-to-use workflow (SARIF code-scanning + inline PR annotations + release gate) ships with the Android sample at
SampleAppAndroid/.github/workflows/pii-masking.yml.
GitLab CI
pii-masking:
image: python:3.12-slim
script:
- pip install luciq-masking-linter
# warn on branches, enforce on the default branch
- |
if [ "$CI_COMMIT_REF_NAME" = "$CI_DEFAULT_BRANCH" ]; then
luciq-masking-linter . --mode enforce
else
luciq-masking-linter . --mode warn
fi
Bitbucket Pipelines
pipelines:
pull-requests:
'**':
- step:
script:
- pip install luciq-masking-linter
- luciq-masking-linter . --mode warn
branches:
main:
- step:
script:
- pip install luciq-masking-linter
- luciq-masking-linter . --mode enforce
Bitrise
Add a Script step. Bitrise exposes the current branch as $BITRISE_GIT_BRANCH, so
warn on branches and enforce on main:
workflows:
pii-masking:
steps:
- script@1:
title: Luciq PII masking
inputs:
- content: |-
#!/usr/bin/env bash
set -euo pipefail
pip3 install luciq-masking-linter
# warn on branches, enforce on the default branch
if [ "$BITRISE_GIT_BRANCH" = "main" ]; then
luciq-masking-linter . --mode enforce
else
luciq-masking-linter . --mode warn
fi
Jenkins (declarative)
stage('PII masking') {
steps {
sh 'pip install luciq-masking-linter'
// non-zero exit fails the stage in enforce mode
sh 'luciq-masking-linter . --mode enforce'
}
}
Pre-commit / local git hook
Run it as a plain command before each commit — same exit-code contract:
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: luciq-masking-linter
name: Luciq PII masking
entry: luciq-masking-linter . --mode enforce
language: system
pass_filenames: false
IDE integration
By default the linter prints a console report; --format surfaces findings in the IDE.
Xcode — inline warnings
Add a Run Script build phase (target → Build Phases → + → New Run Script Phase), move it last, and uncheck "Based on dependency analysis" so it runs every build:
luciq-masking-linter "$SRCROOT" --format xcode --mode warn || true
# Without the install:
# python3 "$SRCROOT/luciq_masking_linter.py" "$SRCROOT" --format xcode --mode warn || true
Findings then appear as warnings in the editor and Issue navigator on every build.
--mode warn keeps them non-blocking locally; switch to --mode enforce to turn hard
failures into red build errors.
Android Studio
On Android you have two ways to run the gate. Pick one — most Android teams want the native Lint path and never touch Python.
-
Native Android Lint — the full gate, no Python (recommended). This repo ships a custom Android Lint check —
android-lint/, coordinateai.luciq:luciq-masking-lint— that ports the entire engine to Kotlin: compliance dialing, customluciq.ymlkeywords, the GDPR/HIPAA input floor, and project posture. Add one dependency and./gradlew lint/check/CI blocks on a masking gap (hard-fail issues default toerror):dependencies { lintChecks("ai.luciq:luciq-masking-lint:0.2.0") }
Findings show in the editor and in
./gradlew lintreports (HTML/XML/SARIF). It reads the sameluciq.ymlas the CLI. See the module README for the issue/severity map and the local composite-build setup. -
Python CLI via a Gradle task (cross-platform parity). If you'd rather run the same command iOS/CI use, register a task that shells out to the CLI; its
file:lineoutput is clickable in the Build tool window:tasks.register<Exec>("luciqPiiLintEnforce") { // exit 1 on a hard finding commandLine("luciq-masking-linter", projectDir.absolutePath, "--mode", "enforce") } tasks.matching { val n = it.name n.startsWith("assemble") || n.startsWith("install") || (n.startsWith("bundle") && (n.endsWith("Debug") || n.endsWith("Release"))) }.configureEach { dependsOn("luciqPiiLintEnforce") }
Hang it off
assemble*/install*/bundle<Variant>— notpreBuild/compile, which would also gate:app:lintand unit tests. Scope toassembleRelease/bundleReleaseonly for release-only gating.
The Kotlin Lint engine is a hand port of the Python tool and is kept in lockstep with it (a parity test cross-checks the two). The Python CLI remains the cross-platform source of truth — it also covers iOS — but on Android the Lint check is a complete, equivalent gate.
Exit codes
| Code | Meaning |
|---|---|
0 |
No hard failures, or running in --mode warn (which never blocks). |
1 |
--mode enforce and at least one hard failure — the gate blocks. |
Development
# from the repo root
python3 -m unittest discover -s tests -v
# or, if you have pytest:
python3 -m pytest tests -q
The project is a single module (luciq_masking_linter.py) plus tests/ and docs/. Adding
a platform means adding one entry to the ADAPTERS registry — see
docs/spec.md.
The honest limit
The gate proves your masking controls are configured and that recognizable PII views are covered. It cannot prove a value is masked when nothing in the code signals it's PII (a generically-named field), nor that a region actually renders black at runtime. Those remain a human pre-production check. This gate raises the floor; it does not replace that review.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file luciq_masking_linter-0.1.0.tar.gz.
File metadata
- Download URL: luciq_masking_linter-0.1.0.tar.gz
- Upload date:
- Size: 21.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
deb67c7e23ac28f301cb3620fe6580077d80fa1cc9e973deba0c41d08bff543e
|
|
| MD5 |
c7f49f767d7d5be0459aa8eec6e0af4c
|
|
| BLAKE2b-256 |
d51a356ebf080c91ecb2c4907cf82561308237e5de0e2b7acb4dbe9b55ceaae6
|
Provenance
The following attestation bundles were made for luciq_masking_linter-0.1.0.tar.gz:
Publisher:
release-pypi.yml on luciqai/luciq-masking-linter
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
luciq_masking_linter-0.1.0.tar.gz -
Subject digest:
deb67c7e23ac28f301cb3620fe6580077d80fa1cc9e973deba0c41d08bff543e - Sigstore transparency entry: 2012763052
- Sigstore integration time:
-
Permalink:
luciqai/luciq-masking-linter@dc46e2d132fec720c392eb1f3a7dc456ec655bcf -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/luciqai
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-pypi.yml@dc46e2d132fec720c392eb1f3a7dc456ec655bcf -
Trigger Event:
push
-
Statement type:
File details
Details for the file luciq_masking_linter-0.1.0-py3-none-any.whl.
File metadata
- Download URL: luciq_masking_linter-0.1.0-py3-none-any.whl
- Upload date:
- Size: 18.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
68a8c36a30efd5c9a255f637a79ed3e5f42d831507dbced490787657c43bcef9
|
|
| MD5 |
75ef4f6aad138270e101bf33148259e6
|
|
| BLAKE2b-256 |
e2c9aef963f7c44f241f2ffe41bf47e6eb3c226e17c5f186516ed4f5f1edf5ef
|
Provenance
The following attestation bundles were made for luciq_masking_linter-0.1.0-py3-none-any.whl:
Publisher:
release-pypi.yml on luciqai/luciq-masking-linter
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
luciq_masking_linter-0.1.0-py3-none-any.whl -
Subject digest:
68a8c36a30efd5c9a255f637a79ed3e5f42d831507dbced490787657c43bcef9 - Sigstore transparency entry: 2012763124
- Sigstore integration time:
-
Permalink:
luciqai/luciq-masking-linter@dc46e2d132fec720c392eb1f3a7dc456ec655bcf -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/luciqai
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-pypi.yml@dc46e2d132fec720c392eb1f3a7dc456ec655bcf -
Trigger Event:
push
-
Statement type: