Luciq PII Masking Linter
A release gate that statically catches unmasked PII — card numbers, SSNs, emails, passwords, and more — in your app that is integrated with Luciq-SDK before it ships, across the surfaces the SDK protects: Screenshots, Session Replay, and Network logs.
On Android the linter ships as a native Android Lint check (a Kotlin lint.jar). You add it as a single lintChecks(...) dependency — no Python required. From that one dependency you get:
Live squiggles in Android Studio as you type on unmasked PII fields.
A build/CI gate:
./gradlew :app:lint(andcheck) fail on a masking gap because the hard-fail issues default toERRORand Lint'sabortOnErroris on by default.The full engine — compliance dialing, custom
luciq.ymlkeywords, the GDPR/HIPAA input floor, and whole-project posture checks — identical to the cross-platform CLI.
Requirements
Android Gradle Plugin / Lint
Built against Lint API 32.0.1 (AGP 9.x; convention: lint version = AGP version + 23.0.0).
JDK
17 (the Lint 32.x line targets JDK 17).
Luciq SDK
≥ 14.2.0 recommended — network auto-masking is on by default from this version. Older versions are flagged.
Install
1. Add JitPack to settings.gradle.kts:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") } // ← add this
}
}2. Add the check to app/build.gradle.kts:
How it runs
The check operates in two complementary passes so findings never double-report:
Per-field, live (as-you-type). A UAST file handler scans the in-memory editor buffer for the file you are editing, so Android Studio shows the squiggle immediately — no save or full build required. This pass emits
unmasked-fieldfindings.Project posture, whole-project (batch). Auto-mask configuration, network masking,
FLAG_SECURE, consent gating, and SDK version depend on the whole project, so they run once per project during./gradlew lint.
Findings are reported with a [check-id] prefix in the message (for example [unmasked-field] card field is not masked …), surfaced under one of the four Lint issue ids below.
The four issue ids
Use these ids in lint { } / lint.xml to tune the check.
LuciqUnmaskedPii
error (blocks)
A PII field (input or label) that is not masked and not waived.
LuciqMaskingConfig
error (blocks)
Project posture: missing auto-mask or MASK_NOTHING, network masking disabled, FLAG_SECURE overridden, missing consent gate, or an old SDK.
LuciqUnmaskedPiiAdvisory
warning
A field finding that does not block at the current compliance level.
LuciqMaskingConfigAdvisory
warning
A non-blocking posture advisory.
Severity → issue mapping inside the engine:
hard-fail field →
LuciqUnmaskedPii(ERROR)soft-warn field →
LuciqUnmaskedPiiAdvisory(WARNING)hard-fail posture →
LuciqMaskingConfig(ERROR)soft-warn posture →
LuciqMaskingConfigAdvisory(WARNING)
All four issues are in the SECURITY category, vendor "Luciq".
Tuning — Lint settings
Out of the box the check blocks: ./gradlew :app:lint (and CI) fail on a masking gap. You tune everything in app/build.gradle.kts, inside android { lint { … } }.
Most common: show findings as warnings only (squiggles, but never fail the build).
You still get the editor squiggles and report entries, but the build stays green.
Other knobs in the same lint { } block:
Turn a check off completely
disable += "LuciqUnmaskedPii"
Keep errors but don't fail the build (project-wide)
abortOnError = false
Block only new findings
baseline = file("lint-baseline.xml")
Write SARIF for GitHub Code Scanning
sarifReport = true
abortOnError is Lint's own default-on switch: an ERROR finding fails lint / check / CI unless you disable it. Per-issue severity (warning +=, disable +=) and lint.xml also work as with any Lint check.
What it checks
Project posture (LuciqMaskingConfig / …Advisory)
LuciqMaskingConfig / …Advisory)auto-mask-config
Visual
No auto-mask configured (setAutoMaskScreenshotsTypes absent), or it is set to MASK_NOTHING, or — under HIPAA — MEDIA is missing from the configured types.
network-disabled
Network
Network auto-masking is explicitly turned off via Luciq.setNetworkAutoMaskingState(Feature.State.DISABLED).
network-sdk-version
Network
The detected Luciq SDK version is < 14.2.0 (hard fail); or the version could not be determined (warning).
flag-secure
Defense
ignoreFlagSecure(true) is present — it overrides FLAG_SECURE, so secure windows get captured.
consent-gate
Defense
Under GDPR/HIPAA: SessionReplay.enabled = true is not guarded by a consent check.
Per-field coverage (LuciqUnmaskedPii / …Advisory)
LuciqUnmaskedPii / …Advisory)For each candidate field the engine asks, in order:
Is it a PII candidate? A text input or display label whose name matches a built-in or custom keyword family. Candidacy is name-driven by default — a
TextField/EditTextis flagged only when its name matches a family, never merely for being an input. (Exception: the GDPR/HIPAA input floor, below.)Is it masked? Covered by auto-mask of its type, an inline private marker, or a reference in
addPrivateViews(...)/setPrivateView(...).Is it waived? An inline
// luciq-mask-ignorecomment.If none of the above → finding.
Candidate detection runs on the comment-stripped source, so a field or widget name that appears only in a comment is never flagged, and a commented-out marker or config call never counts as real, active masking.
How to fix a finding (mask the field)
Mask the flagged field with any of these:
Or clear a whole class of fields in bulk by configuring auto-mask at init:
A clean setup (passes the gate) looks like:
Skip a finding (waivers)
If a flagged field isn't really PII, skip it with a comment:
// luciq-mask-ignoreon the field's line (or the line just above it) — waives that one field.// luciq-mask-ignore-fileanywhere in a file — waives every candidate in that file.Add a reason for the audit trail:
// luciq-mask-ignore: masked upstream. The reason is surfaced in the finding.
The legacy aliases // luciq-ignore and // luciq-ignore-file still work.
Card fields can't be waived under
pci. A card-family waiver (line- or file-level) is refused and reported as a hard failure when compliance ispci.
Configure — luciq.yml
luciq.ymlPut one optional file at your project root. It holds the only two things the linter cannot read from your code: the compliance level and custom keyword families. Both this Lint check and the CLI read it. The check looks for luciq.yml (or luciq.yaml) in the nearest ancestor directory of the module.
On Android, compliance comes only from
luciq.yml. The CLI's--complianceflag andLUCIQ_COMPLIANCEenv var are command-line concepts and do not apply to the Lint check.
Compliance levels — what blocks vs. warns
The level does not add new checks; it turns dials on the existing ones.
none / soc2
card, SSN, credentials
pci
same, and card waivers are refused
gdpr
+ email, phone, name, address, DOB, every text input (input floor); consent required
hipaa
all of the above + health; MEDIA auto-mask + consent required
The GDPR/HIPAA input floor. Under gdpr and hipaa, masking all user input is mandatory, so the synthetic input family kicks in: every text input is a candidate regardless of name. PII inputs are normally cleared in bulk via TEXT_INPUTS auto-mask.
Keyword families
Candidacy is name-driven against these built-in families. Each family's default severity is promoted to a hard fail by the levels in the last column.
card
PAN, CVV, bank account, IBAN
cardNumber, pan, cvv, iban, accountNumber
all levels (no waiver under pci)
ssn
National / tax / social-security ids
ssn, nationalId, taxId
all levels
credentials
Passwords, PINs, OTPs, secrets, tokens
password, pin, otp, apiKey, accessToken
all levels
health
PHI — diagnosis, MRN, medication
diagnosis, mrn, medication, patientId
hipaa
dob
Date of birth
dob, birthDate, dateOfBirth
gdpr, hipaa
email
Email addresses
email, userEmail
gdpr, hipaa
phone
Phone / mobile numbers
phone, msisdn, mobileNumber
gdpr, hipaa
address
Postal / home address
homeAddress, street, postalCode
gdpr, hipaa
name
Person names
firstName, lastName, patientName
gdpr, hipaa
input
Synthetic: any unnamed text input
every EditText / TextField
gdpr, hipaa
Custom keywords. Add app-specific stems under the closest built-in family so compliance severity applies. Matching is case- and separator-insensitive: a stem matches camelCase, snake_case, kebab-case, and spaced spellings alike — fiscalCode catches userFiscalCode, fiscal_code, fiscal-code, and fiscalCodeNumber. Keep custom keywords as stems, never frozen exact names.
Custom families are always
warn— even under HIPAA. Severity is promoted only for families a preset lists by name. To make an app-specific keyword block, nest its stem under a built-in family (e.g.ssn) rather than inventing a new family name.
Full unmasked-field severity matrix
unmasked-field severity matrixfail blocks; warn only reports; — means the field is not a candidate at that level.
card
fail
fail
fail
fail
fail
ssn
fail
fail
fail
fail
fail
credentials
fail
fail
fail
fail
fail
health
warn
warn
warn
warn
fail
dob
warn
warn
warn
fail
fail
email
warn
warn
warn
fail
fail
phone
warn
warn
warn
fail
fail
address
warn
warn
warn
fail
fail
name
warn
warn
warn
fail
fail
input (unnamed input)
—
—
—
fail
fail
custom family
warn
warn
warn
warn
warn
Posture severity matrix (non-field checks)
auto-mask-config (absent / MASK_NOTHING)
fail
fail
fail
fail
fail
auto-mask-config (MEDIA required)
—
—
—
—
fail
card waiver refused
—
—
fail
—
—
network-disabled
fail
fail
fail
fail
fail
network-sdk-version (< 14.2.0)
fail
fail
fail
fail
fail
network-sdk-version (unknown)
warn
warn
warn
warn
warn
flag-secure
fail
fail
fail
fail
fail
consent-gate
—
—
—
fail
fail
Exclusions
Skip paths with an exclude: list under pii_masking in luciq.yml. A pattern matches a whole relative path (app/generated/*) or any single path segment (Tests skips every directory named Tests). The check also always skips build, Pods, node_modules, .git, DerivedData, and Carthage.
CI
On Android, CI needs no Python — just run Lint:
For GitHub Code Scanning, enable sarifReport = true in the lint { } block and upload the generated SARIF.
./gradlew :app:lint fails the build on a blocking gap the same way the CLI's --mode enforce does — unless you set abortOnError = false.
Last updated