Skip to content

Releases: snaka/privmask

v0.4.0

Choose a tag to compare

@github-actions github-actions released this 20 Sep 09:39
v0.4.0
a41bc88

What's Changed

  • Describe the --json report, and stop promising a Raycast extension that is not out by @snaka in #20
  • chore(ci): bump actions to Node 24 runtimes before Node 20 removal by @snaka in #21
  • Generate the family-name list from SudachiDict by @snaka in #23

Full Changelog: v0.3.1...v0.4.0

v0.3.1

Choose a tag to compare

@github-actions github-actions released this 13 Sep 03:21
v0.3.1
d69e514

Masks less, and stops breaking the documents it was handed.

brew install snaka/tap/privmask

What changed

Pointed at a real error-tracker payload, 0.3.0 returned 8 findings and 7 of them
were wrong — on a document that held no personal information at all. Three rules
reached too far. 0.3.1 returns one finding on that same payload, the token that
is genuinely a secret.

A bare digit run now has to start with 0 to be a phone number. Every
Japanese number carries the domestic prefix — fixed-line, mobile, PHS, IP (050),
0120 and 0800 alike — so a ten-digit Unix timestamp is not one. This matters
more than it sounds: a JSON number cannot have a leading zero either, so no bare
digit run in a numeric position is reachable any more.

-  "timestamp": [PHONE_1],
+  "timestamp": 1789049614,

The old behaviour did not just over-mask, it produced output that no longer
parsed. Numbers written with separators or +81 were never affected and still
are not.

A value something else already scrubbed is no longer a finding. A run of
******, or a sentinel like [FILTERED], is left alone. Masking one replaced a
mark that was already safe with [SECRET_1], which spends a placeholder number
and tells the reader a distinct secret stood there. xxxxxx, <your-key-here>
and numeric values are unchanged — they are still reported at low confidence,
because a real credential could be sitting in them.

Hiragana that is grammar is no longer read as a name. Kanji and katakana
candidates from the on-device model were checked against a family-name list;
hiragana was not checked against anything, so the words the model reaches for
when a text contains no names came through whole:

-  "message": "[NAME_1]を見れば良い[NAME_2]?",
+  "message": "どこを見れば良いのでしょうか?",

Hiragana given names are unaffected. The new list denies rather than allows, for
exactly that reason — さくら, のぞみ, こころ and the rest cannot be
enumerated, and a list that tried would lose the ones it missed in silence. Names
that collide with grammar are kept as names: そう (蒼, 奏), こう (航, 光) and
なる (成) are deliberately absent from the list, because a false positive is
visible and a missed name is not.

What this costs

A Japanese phone number whose leading 0 has been stripped — by a spreadsheet,
say — is no longer detected. That is the same trade the existing length rule
makes, and it is the direction this tool chooses: a number you have to notice
yourself, rather than a document privmask has broken.

Nothing else moved

No change to --json, to the CLI, or to the library the Raycast extension uses.

docs/findings/japanese-word-classification.md is new. It records why the
hiragana case is a hand-written list rather than a rule: the system has no
Japanese part-of-speech model, NLTokenizer token counts misclassify in both
directions, there is no Japanese word embedding, and NLContextualEmbedding
scores 14/20 on held-out spans while putting のでしょうか on the name side.

privmask 0.3.0

Choose a tag to compare

@github-actions github-actions released this 12 Sep 23:13
v0.3.0
144a0d3

Reads the whole document, not the first 1,500 characters of it.

brew install snaka/tap/privmask

What changed

The old limit was a quota on lines, not on volume: the text was packed a line
at a time and stopped at the first line that would not fit. That suits a log; it
does not suit a written report, where a paragraph is one line. A 10,000-character
incident report whose Japanese was three paragraphs lost the third one entirely.

The text is now sent in as many calls as it takes, and a paragraph longer than
one call is split at a sentence end. Nothing is dropped for being late in the
document. If one chunk fails, the other chunks' names are kept and the failure is
reported.

The cost is time. The on-device model runs one call at a time, so a document
with a lot of Japanese now takes proportionally longer — where before it was fast
and incomplete. A terminal gets a progress line on stderr; a pipe gets nothing.

Breaking: the shape of --json

  • modelInputTruncated is gone. Nothing is truncated now.
  • model is one of used, disabled, unavailable, failed — a closed
    set. It used to be a sentence.
  • modelDetail carries the reason, or null.
  • warnings is new, and it is the field to check: it is empty if and only
    if every layer ran over the whole input.
    The same strings now go to stderr in
    both modes.

The Raycast extension depends on the PrivMask library rather than the CLI, so
it is unaffected.

--help also gained a section addressed to an agent rather than a person, and
privmask file.txt now says to use stdin instead of unknown option.

Limits worth knowing

  • A name competing with others on the same line is missed, run after run
    not occasionally, every time. Smaller chunks find it and make the model mask
    commit hashes instead; measured across seven sizes and rejected
    (#1).
  • Japanese addresses are not detected if your Mac is English-language with a
    non-Japanese region.
    Either one being Japanese is enough; with neither,
    nothing is detected and privmask does not warn. Found while releasing this
    version, not yet fixed (#13).
  • A chunk holding no names can make the model run away; that now ends as a
    reported failed chunk, so you can see a warning for text that had nothing to
    find.
  • 0.2.0's limits still apply: a credential with neither a recognisable name nor a
    published prefix is not found, an unquoted value is masked only to the first
    delimiter, and masking is not reversible.

Detail: #9 (batching), #10 (the agent-facing CLI), #11 (chunk size).

privmask 0.2.0

Choose a tag to compare

@snaka snaka released this 12 Sep 05:56
v0.2.0
11c48b1

Finds credentials that have no recognisable shape.

brew install snaka/tap/privmask
cat app.log | privmask

What changed

0.1.0 knew three published key prefixes — AWS access key IDs, GitHub tokens,
OpenAI keys — and nothing else. A value with no shape to match on went straight
through, including an AWS secret access key, which is 40 base64-ish
characters with no prefix. The half that was detected was the half that is not
the secret.

There are now two routes to a credential.

A published prefix, recognised by its shape. Added in this release: GitHub
fine-grained personal access tokens, Slack tokens and incoming webhooks, Google
API keys, Stripe secret and restricted keys, npm tokens, SendGrid keys, JWTs,
private-key PEM blocks, and credentials embedded in a URL — where only the
password is masked, because the user name is not the secret and hiding it costs
you the answer to which account the failure was under.

The name that introduces the value. The right-hand side of api_key = "…"
is a secret whatever it contains, so this reaches the AWS secret access key, a
key issued by a service that never published a prefix, and a password written in
Japanese, all by the same rule.

AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYzTBLURKEY
  → AWS_SECRET_ACCESS_KEY=[SECRET_1]

Authorization: Negotiate YIIGabcdef1234567890
  → Authorization: Negotiate [SECRET_2]

DATABASE_URL=postgres://app:hunter2@db.internal:5432/orders
  → DATABASE_URL=postgres://app:[SECRET_3]@db.internal:5432/orders

What it deliberately leaves alone

Masking a line that was never sensitive destroys the report you were trying to
share, so the name rule is narrow on purpose:

token_count: 1500                    secretName: db-tls-cert
api_key_count: 3                     password_file: /run/secrets/db_password
auth: enabled                        authorization_url: https://idp.example.com/…

A name matches on whole words, never substrings — secretary: 山田健一 is a
person, not a key. A bare token does not claim, because it matches
token_count: 1500 and 入力トークン数. And a name whose last word is count,
file, path, name, id, length or url says where a secret lives rather
than being one.

Scoring values by randomness was considered and rejected: it would also flag the
commit hashes, UUIDs and request IDs that a report needs to keep.

A value that cannot be live — YOUR_API_KEY_HERE, xxxxxxxx, digits only,
${AWS_SECRET} — is reported at low confidence rather than dropped. A rule that
dropped it would eventually drop a real numeric password, and nobody would see
it go.

Limits worth knowing

  • An unquoted value is masked only as far as the first space, quote, comma,
    semicolon or closing bracket, so a passphrase containing spaces is only partly
    covered.
  • A value with neither a name nor a published prefix is not found at all.
  • Names are ASCII, so パスワード: hunter2 is not a claim.
  • secret_id and secret_url are treated as references by the rule above,
    which means a Vault AppRole SecretID or a pre-signed URL is missed.
  • The CLI has no confirmation step and masks low-confidence findings too.

Also

--json findings now carry a credentialContext source, distinguishing a
name-based finding from a shape-based one. When both find the same value they
merge into one finding and the confidence is promoted.

Tests 41 → 87. The design is in #2 and the implementation plan in #3, both
carrying corrections for the four defects this work found in them — every one of
which put a placeholder in the output while leaving the credential beside it.

privmask 0.1.0

Choose a tag to compare

@snaka snaka released this 09 Sep 22:57
v0.1.0
131f3b3

First release. Masks personal information in text before you share it, entirely
on device.

brew install snaka/tap/privmask
cat app.log | privmask

What it finds

Phone numbers and addresses through NSDataDetector, including full-width and
unhyphenated Japanese formats. My Numbers, validated by check digit so an order
number is not mistaken for one. Email addresses, postal codes, and AWS, GitHub
and OpenAI key shapes. Your own terms from ~/.config/privmask/terms.txt.
Japanese personal names through Apple Intelligence's on-device model, and
English names through NLTagger.

Values are replaced with numbered placeholders — [NAME_1], [EMAIL_2] — and
the same value always gets the same number, so a reader can still follow who is
who.

Read this before you rely on it

Japanese personal names are found only by Apple Intelligence's on-device
model.
Without macOS 26 and Apple Intelligence enabled, names are not detected
at all — NLTagger has no Japanese entity model, which is a platform
limitation rather than something this tool can work around. privmask says so on
stderr whenever the model did not run. Read those warnings: pasting something
believing it was masked when it was not is the worst outcome this can produce.

Other limits: only the first 1,500 characters of Japanese are examined for
names; the model varies between runs; your own terms are matched exactly unless
the model is available; and masking is not reversible.

Requirements

macOS 13 or later. Everything except Japanese personal names works throughout
that range. The binary is universal (Apple silicon and Intel) and unsigned.