A lightweight Java security framework behind one facade — NS.
Three schema-driven engines: a masking log engine, a field-level
crypto engine (AES-GCM, RSA-OAEP, HMAC, PBKDF2) and a unique-id
engine (ULID, UUIDv7, SNOWFLAKE) — all discovered from the classpath.
ns-secure is a unified security facade for the JVM. Application code talks to a single entry
point — NS — and never to a provider directly. Which engine answers
is decided by the operation, the schema's type, and what is on your
classpath: implementations register through java.util.ServiceLoader,
so adding a module is the only wiring there is.
Every schema is a versioned JSON resource with a declared kind — LOG,
GENERATE or CRYPTO/ENCRYPT
family. The log engine masks events deny-by-default; the id engine
fills empty id fields with ULIDs, UUIDv7s or custom-patterned keys; the crypto
engine encrypts, hashes or drops individual fields, binding every ciphertext to its
<schema>/<field> context so values can't be transplanted.
Around the engines, the same facade offers a plain-message logger that scrubs stack traces and a
per-thread NSLogContext for trace/span/request correlation —
all Java 8+, all with zero third-party dependencies outside the log module's logback-core.
// one facade, every operation
NS.use(NSOp.LOG, LOGIN_SCHEMA, event); // masked log line
NS.use(NSOp.GENERATE, ORDER_CREATE, order); // fill empty id fields
NS.use(NSOp.CRYPTO, CUSTOMER_WRITE, record); // field-level encrypt
NS.use(NSOp.VERIFY, attempt, storedHash); // constant-time check
String id = NS.use(NSOp.ID); // one ULID
// plain messages — scrubbed, stack traces included
NS.log(OrderController.class).info("Order {} shipped", id);
// uniqueness-checked generation, DB stays the authority
NS.use(NSOp.GENERATE, ORDER_CREATE, order,
(field, v) -> repo.exists(field, v));
NS facade, NSOp, typed NSSchema (LOG / GENERATE / CRYPTO), NSLevel, NSLogContext, SPIs & registry, internal JSON. Zero deps.
Schema-driven masking engine + logback-core appenders (console, rolling file, TCP, Splunk HEC). Discovered via META-INF/services.
Field-level crypto: AES-GCM, ChaCha20-Poly1305, RSA-OAEP, HMAC-SHA256, PBKDF2, legacy AES-CBC. Key refs, not keys, in schemas.
ULID, UUIDv7, UUIDv4, SNOWFLAKE, RANDOM, NUMERIC, PATTERN — coordination-free, with uniqueness-check support.
The ns-secure umbrella artifact
pulls all modules in one dependency — or depend on a single engine to keep the classpath minimal.
Eight capabilities that turn "don't log PII" from a code-review hope into an enforced pipeline property.
Each event type is a versioned JSON schema: name, level, message and a per-path rule map. Validated and compiled at class-init — bad schemas fail fast.
A field with no rule is suppressed (unlisted policy: SUPPRESS, REDACT or ALLOW).
Forgot to classify it? It never leaves the process.
a.b, list[*].x, list[0],
**.iban, metrics.**. The most specific path wins;
ties resolve to the stronger strategy.
EMAIL, CARD (Luhn-checked), SSN, PHONE, IP, DOB, CREDENTIAL rules plus
REG_EXP: customs. Allowed text is still scrubbed for secrets.
PSEUDONYM_RULE emits keyed tokens — correlate a user's events
without ever logging the identity. Secret via env:/sysprop: ref.
Events below logging.level are not written; AUDIT always is.
Per-level policies can force a strategy or tighten unlisted at ERROR.
NSLogContext.with("traceId", …) attaches correlation IDs to every
line — allowlisted keys only, since context values are not masked.
STDOUT by default; drop in ns-secure-logback.xml for console,
rolling file, async, TCP and Splunk HEC appenders — logback-core only, no slf4j needed.
NS.use(NSOp.CRYPTO, …) encrypts, hashes or drops each field per
its rule's algo. Ciphertexts are AEAD-bound to
schema/field — they can't be transplanted.
NS.use(NSOp.GENERATE, …) fills only empty id fields — ULID,
UUIDv7, SNOWFLAKE, patterned keys — with an optional uniqueness callback against your store.
NS.use() to a safe log lineClick any stage to see what happens inside it. Data only moves left-to-right through the pipeline — there is no path that skips masking.
NS.use(NSOp.GENERATE, schema, record)NS.use(NSOp.CRYPTO, schema, record)Every capability is an interface in core: NSLogService,
NSIdService, NSSchemaCryptoService,
plus key resolution via NSKeys.register(resolver).
Implement yours, register it in META-INF/services or via
NSServiceRegistry.register(), and the facade serves it —
ideal for HSM/KMS-backed crypto or test sinks.
The bundled engines are just SPI implementations themselves — there is no privileged path.
One classpath file — /ns-secure.json — tunes the engine:
service name, level floor, unlisted policy, content rules, per-level policies,
context keys, mask character, pseudonym secret ref. Every key optional, every typo
a startup error.
Environment-aware: ns.secure.env=prod makes the
engine refuse scrubbing: OFF unless you explicitly allow it.
| Rule | Strategy | Matches |
|---|---|---|
CREDENTIAL_RULE | REDACT | tokens — ghp_, xox, sk, AKIA, JWT eyJ |
CARD_RULE | LAST4 | 13–19 digit numbers, Luhn-validated |
SSN_RULE | LAST4 | ddd-dd-dddd |
EMAIL_RULE | PARTIAL | RFC-shaped addresses |
PHONE_RULE | LAST4 | international / local phone shapes |
IP_RULE | PRESERVE_FORMAT | IPv4 dotted quads |
DOB_RULE | REDACT | yyyy-MM-dd |
PSEUDONYM_RULE | PSEUDONYM | any value → stable keyed token |
REDACT_RULE | REDACT | any value |
ALLOW_RULE | NONE | passes — still scrubbed by content floor |
ALLOW_RAW | NONE | passes — exempt even from content floor |
REG_EXP:<re> | REDACT | your own pattern, inline in the schema |
Pattern rules only apply when the value actually matches; a non-matching field falls through
to the next rule or the unlisted policy.
Hover a bar — strategies are ranked by strength. Ties between equally specific paths resolve to the stronger strategy.
Per-level policies can force a strategy globally — e.g.
"DEBUG": {"forceStrategy": "REDACT"} turns even verbose
debug output into fully redacted lines.
Logging is only the first NSOp. The same typed-schema
pattern drives field-level cryptography and unique-id generation — pick the artifact, the
classpath discovers the engine.
The schema's top-level type sets the direction —
ENCRYPT/DECRYPT for symmetric,
SEAL/OPEN for public-key — and each rule's
algo says what happens to that field. Keys live in
keyRef references, never in code or schemas.
| Algo | Kind | Use |
|---|---|---|
AES-GCM | reversible | default AEAD field encryption |
ChaCha20-Poly1305 | reversible | AEAD on Java 11+ |
RSA-OAEP | reversible | SEAL/OPEN hybrid, per-rule keyRef |
AES-CBC | reversible | legacy interop — requires "legacy": true |
HMAC-SHA256 | irreversible | "searchable": true → blind index |
PBKDF2 | irreversible | passwords; VERIFY re-derives from stored salt+cost |
PLAIN / DROP | none | pass through / remove the field |
Every value is AEAD-bound to <schema>/<field> — a ciphertext
copied into another field or schema fails to decrypt.
env:NAME (Base64/PEM), file:/path,
file:/ks.p12#alias — plus custom vault:/kms:
schemes via NSKeys.register(). New keys with NSKeys.generate().
NS.use(NSOp.VERIFY, attempt, stored) re-derives PBKDF2 with its own
salt and cost; everything else compares in constant time.
{
"name": "customer", "type": "ENCRYPT",
"keyRef": "env:NS_SECURE_KEY", "strict": true,
"rules": {
"ssn": { "algo": "AES-GCM" },
"payload": { "algo": "RSA-OAEP", "keyRef": "vault:payments-pub" },
"email": { "algo": "HMAC-SHA256", "searchable": true },
"password": { "algo": "PBKDF2" },
"consentId": { "algo": "PLAIN" },
"notes": { "algo": "DROP" }
}
}
// same rules, direction flips with the schema type
NS.use(NSOp.CRYPTO, CUSTOMER_W, data); // ENCRYPT
NS.use(NSOp.CRYPTO, CUSTOMER_R, row); // DECRYPT — tokens & hashes stay as-is
{
"name": "order.create", "type": "GENERATE",
"rules": {
"orderId": "ULID",
"publicRef": "UUID4",
"correlationId": "UUID7",
"otp": "NUMERIC:6",
"apiKey": { "type": "RANDOM", "alphabet": "base62",
"bits": 256, "prefix": "sk_live_" },
"licenseKey": { "type": "PATTERN",
"regex": "[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}" }
}
}
// fills only missing / null / "" fields — idempotent
NS.use(NSOp.GENERATE, ORDER_CREATE, order);
// regenerate on collision; the DB stays the authority
NS.use(NSOp.GENERATE, ORDER_CREATE, order,
(field, v) -> repo.exists(field, v));
A "type": "GENERATE" schema names which fields get ids and in what format.
Nested paths and items[*].id arrays work; everything except
SNOWFLAKE is coordination-free — any node mints alone, no shared state.
| Type | Shape |
|---|---|
ULID · UUID7 | time-ordered, sort-friendly |
UUID4 | random UUID |
SNOWFLAKE | 64-bit, needs a unique nodeId per instance |
RANDOM | alphabet · bits/length · prefix · case · "groups": "4-4-4" |
NUMERIC:n | digits, e.g. 6-digit OTP |
PATTERN | generates to a regex — license-key shapes |
Presets for the common cases: API_KEY, PUBLIC_ID,
SORT_KEY, HUMAN_CODE, OTP.
Shorthands: RANDOM:<bytes>[:prefix], NUMERIC:<len>,
REG_EXP:<regex>.
The engine warns at schema-compile time when a key-like field has under 64 bits of entropy — collision and guessing risk flagged before production, not after.
Three browser-side simulations, one per engine:
A browser-side simulation of NSDefaultLogService — same rules, same deny-by-default.
Edit the schema or the event, then run it. Notice that password has no rule — it never appears.
Try it: remove a rule from the schema and watch the field disappear entirely; add
"department": "ALLOW_RULE" to keep it; change
requestBody to ALLOW_RAW to bypass the content floor
(the card number comes through — exactly why it's opt-in).
A browser-side simulation of the id engine.
Only empty fields (missing / null / "") get values — existing ids survive,
so correlationId and lines[1].lineId stay as they are.
Try it: change apiKey's bits to 24 — the engine warns
that the keyspace is too small for unattended generation; run twice and note ULIDs keep ordering.
A browser-side simulation of the crypto engine (deterministic stand-ins, not real AES/PBKDF2). Note the AES-GCM envelope changes every run — fresh nonce — while HMAC tokens and the PBKDF2 hash are stable for the same input.
Try it: cvv has no rule and strict is true —
the strict error names the field but never its value. Remove it (or set strict:false)
and note notes is dropped entirely. On DECRYPT the tokens and hash stay as-is —
only reversible algos come back.
There is no file watcher: the application swaps in a new NSSchema
or registers a new engine, and the next call uses it. Schemas and loggers already in flight pick it up
immediately; a broken replacement is rejected while the previous good one keeps working — verified with 200 live
swaps under 8-thread load: zero errors, zero unmasked data.
| What | Live? | How |
|---|---|---|
| Log rules — paths, masking, level-scoped | Yes | NSSchema.fromJson() → swap into an AtomicReference; bad JSON / unknown rule throws, old schema stays |
| Log config — level floor, service, mask char, message rules | Yes | NSServiceRegistry.register(NSLogService, new NSDefaultLogService(NSLogSettings.fromJson(json), sink)) |
| Outputs & per-logger levels — ns-secure-logback.xml | Yes | New engine with NSLogbackSink.fromXml()/fromUrl(), then close() the old one to flush files/sockets |
| Auto reload on file change | No | Not built in — trigger a reload yourself (admin endpoint, file timestamp check, Spring Cloud refresh, ConfigMap watch) |
| Id formats — length, alphabet, pattern, prefix | Yes | Swap the GENERATE schema |
| Snowflake node id | Yes | register(new NSDefaultIdService(NSIdSettings.withNodeId(n))) — register before loading SNOWFLAKE schemas |
| Crypto policy — algo per field, keyRef | Yes | Swap the ENCRYPT/DECRYPT schema pair (same name) |
| Keys in file / vault | Yes | Resolved on every call — a key replaced in the store is used immediately |
| Keys in env vars | No | A JVM can't change its own env — rotate env: keys with a restart, or use file:/vault: where live rotation matters |
| Data from the previous key | Partly | No built-in keyring — old ciphertext fails with the generic error unless you keep versioned keyRefs (card-key-v1 read, card-key-v2 write) |
// hold schemas in a swappable reference
private static final AtomicReference<NSSchema> ORDER =
new AtomicReference<>(NSSchema.fromResource("/schemas/order.json"));
// POST /admin/schema reload — next call uses new rules
ORDER.set(NSSchema.fromJson(newJson)); // throws on a bad edit
// change engine settings live
NSServiceRegistry.register(NSLogService.class,
new NSDefaultLogService(
NSLogSettings.fromJson(newSettingsJson), // level, maskChar, service
NSLogbackSink.fromXml(newLogbackXml))); // new outputs
// rotate a key in the secrets store — next call uses it
// (env: keys can't change inside a running JVM)
Typical production pattern: your ops UI or a scheduled job re-reads the schema/config source and calls
set()/register() — the framework makes the swap atomic, you decide the trigger.
dependencies {
// umbrella: facade + all three engines
implementation 'io.github.nsprism:ns-secure:0.1.0-beta.1'
// or just the engines you need:
// 'io.github.nsprism:ns-secure-log:0.1.0-beta.1'
// 'io.github.nsprism:ns-secure-crypto:0.1.0-beta.1'
// 'io.github.nsprism:ns-secure-id:0.1.0-beta.1'
}
<dependency>
<groupId>io.github.nsprism</groupId>
<artifactId>ns-secure</artifactId>
<version>0.1.0-beta.1</version>
</dependency>
{
"logging": {
"service": "claims-api",
"level": "INFO",
"unlisted": "SUPPRESS",
"contentRules": ["CREDENTIAL_RULE", "CARD_RULE", "SSN_RULE"],
"levelPolicy": {
"DEBUG": { "forceStrategy": "REDACT" },
"ERROR": { "unlisted": "REDACT" }
}
},
"pseudonym": { "secretRef": "env:NS_SECURE_PEPPER" }
}
<!-- classpath root: console + rolling file + async TCP + Splunk HEC -->
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="io.github.nsprism.nssecure.log.logback.NSJsonEncoder"/>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.json</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d.%i.json.gz</fileNamePattern>
<maxFileSize>10MB</maxFileSize><maxHistory>14</maxHistory>
</rollingPolicy>
<encoder class="io.github.nsprism.nssecure.log.logback.NSJsonEncoder"/>
</appender>
<appender name="SPLUNK" class="io.github.nsprism.nssecure.log.logback.NSSplunkHecAppender">
<url>${SPLUNK_HEC_URL}</url><token>${SPLUNK_HEC_TOKEN}</token>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/><appender-ref ref="FILE"/>
<appender-ref ref="SPLUNK"/>
</root>
</configuration>
No ns-secure.json? Defaults are already safe: INFO floor,
SUPPRESS unlisted, credentials/cards/SSNs scrubbed, per-JVM pseudonym key.
// 1 — declare the schema once (classpath resource)
private static final NSSchema LOGIN =
NSSchema.fromResource("/ns-schemas/auth-login.json");
// 2 — build the event
Map<String, Object> event = new HashMap<>();
event.put("userId", principal.getId());
event.put("sessionToken", token);
event.put("user", user); // nested maps & lists are walked
// 3 — emit; returns the masked payload actually written
NS.use(NSOp.LOG, LOGIN, event);
The difference between a masking library and a security control is what happens when someone makes a mistake. ns-secure is designed around the mistakes.
GDPR, PCI-DSS and HIPAA all care about PII in logs. Deny-by-default means an undeclared field — the classic leak vector — is physically absent from output. Auditors see a policy file, not a promise.
AUDIT level events bypass the level floor unconditionally —
security-relevant records are written even when a service is tuned to ERROR.
Unknown config keys, bad rules, malformed paths and missing secrets all throw at class-initialisation or startup. You cannot deploy a silently-partial policy.
With ns.secure.env=prod, switching scrubbing off is refused
unless allowUnscrubbedInProduction=true — an explicit,
reviewable opt-out.
Core has zero third-party dependencies — even JSON parsing is internal. One small jar to vet, no transitive surprises in your SBOM.
Pseudonymised IDs are stable per secret, so dashboards can still group a user's session — while the raw identity never leaves the service boundary.
JSON lines to stdout work with any collector; the optional logback config adds async, rolling files, TCP and Splunk HEC without pulling slf4j or a logging framework.
HSM-backed crypto, KMS-resolved secrets, a custom log engine — all are SPI implementations. Register them on the classpath; the facade and every call site stay unchanged.
Back to the framework family, or grab the coordinates and start masking.