Skip to main content

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 install but 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

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> and python3 luciq_masking_linter.py <args> are interchangeable.

Requirements

  • Python 3.8 or newer (standard library only — no requirements.txt).
  • PyYAML is optional: if present it's used to parse luciq.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): --complianceLUCIQ_COMPLIANCE env var → luciq.ymlnone.


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 : reason to leave a note: // luciq-mask-ignore: masked upstream.
  • To skip a whole file, put // luciq-mask-ignore-file anywhere 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/, coordinate ai.luciq:luciq-masking-lint — that ports the entire engine to Kotlin: compliance dialing, custom luciq.yml keywords, 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 to error):

    dependencies { lintChecks("ai.luciq:luciq-masking-lint:0.2.0") }
    

    Findings show in the editor and in ./gradlew lint reports (HTML/XML/SARIF). It reads the same luciq.yml as 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:line output 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>not preBuild/compile, which would also gate :app:lint and unit tests. Scope to assembleRelease/ bundleRelease only 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

luciq_masking_linter-0.1.0.tar.gz (21.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

luciq_masking_linter-0.1.0-py3-none-any.whl (18.5 kB view details)

Uploaded Python 3

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

Hashes for luciq_masking_linter-0.1.0.tar.gz
Algorithm Hash digest
SHA256 deb67c7e23ac28f301cb3620fe6580077d80fa1cc9e973deba0c41d08bff543e
MD5 c7f49f767d7d5be0459aa8eec6e0af4c
BLAKE2b-256 d51a356ebf080c91ecb2c4907cf82561308237e5de0e2b7acb4dbe9b55ceaae6

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file luciq_masking_linter-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for luciq_masking_linter-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 68a8c36a30efd5c9a255f637a79ed3e5f42d831507dbced490787657c43bcef9
MD5 75ef4f6aad138270e101bf33148259e6
BLAKE2b-256 e2c9aef963f7c44f241f2ffe41bf47e6eb3c226e17c5f186516ed4f5f1edf5ef

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page