Metadata-Version: 2.4
Name: luciq-masking-linter
Version: 0.1.0
Summary: Release-gate linter that checks Luciq-SDK apps for PII-masking gaps (iOS + Android).
Author: Luciq
License: Apache-2.0
Project-URL: Homepage, https://docs.luciq.ai
Project-URL: Repository, https://github.com/luciqai/luciq-masking-linter
Project-URL: Organization, https://luciq.ai
Keywords: luciq,pii,masking,linter,ci,ios,android,privacy
Requires-Python: >=3.8
Description-Content-Type: text/markdown

# 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](#installation)
- [Quick start](#quick-start)
- [Command-line reference](#command-line-reference)
- [Configuration (`luciq.yml`)](#configuration-luciqyml)
- [Compliance levels](#compliance-levels)
- [Skipping a field](#skipping-a-field)
- [What it checks](#what-it-checks)
- [Output formats](#output-formats)
- [CI integration](#ci-integration)
- [IDE integration](#ide-integration)
- [Exit codes](#exit-codes)
- [Development](#development)
- [The honest limit](#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`.

```bash
# 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:

```bash
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+:

```bash
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.

```bash
# 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](#ci-integration-github-actions)).

---

## 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](#output-formats). |
| `--exclude GLOB` | — | Path glob or directory name to skip. Repeatable; merged with `exclude:` in `luciq.yml`. |
| `-h`, `--help` | — | Show usage. |

Examples:

```bash
# 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:

```yaml
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`](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`](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:

```swift
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`](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`:

```yaml
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:

```yaml
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

```yaml
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

```yaml
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`:

```yaml
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)

```groovy
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:

```yaml
# .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:

```bash
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/`](android-lint/README.md), 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`):
  ```kotlin
  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:
  ```kotlin
  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

```bash
# 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`](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.
