{ "type": "EMAIL_ADDRESS", "placeholder": "[[EMAIL_ADDRESS_1]]" },
{ "type": "PHONE_NUMBER", "placeholder": "[[PHONE_NUMBER_1]]" }
],
- "language": "en"
+ "language": "en",
+ "languageFallback": false
}
```
| `counters` | Final counter values per entity type |
| `entities` | List of detected entities with their placeholders |
| `language` | Language used for PII detection |
+| `languageFallback` | Whether the configured fallback language was used (auto-detection failed) |
## Detection Options
}
```
-### PII Detection Error (503)
+### Detection Error (503)
-Returned when Presidio is unavailable:
+Returned when Presidio or secrets detection is unavailable:
```json
{
"error": {
"message": "PII detection failed",
"type": "detection_error",
- "details": "Failed to connect to Presidio..."
+ "details": [
+ { "message": "Failed to connect to Presidio..." }
+ ]
}
}
```
expect(body.entities.some((e) => e.type === "EMAIL_ADDRESS")).toBe(true);
expect(body.entities.some((e) => e.type === "PEM_PRIVATE_KEY")).toBe(true);
});
+
+ test("returns 400 for malformed JSON", async () => {
+ const res = await app.request("/api/mask", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "not valid json",
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: { type: string } };
+ expect(body.error.type).toBe("validation_error");
+ });
+
+ test("returns 503 when PII detection fails", async () => {
+ mockDetectPII.mockRejectedValueOnce(new Error("Presidio connection failed"));
+
+ const res = await app.request("/api/mask", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: "Contact john@example.com" }),
+ });
+
+ expect(res.status).toBe(503);
+ const body = (await res.json()) as {
+ error: { type: string; message: string; details: { message: string }[] };
+ };
+ expect(body.error.type).toBe("detection_error");
+ expect(body.error.message).toBe("PII detection failed");
+ expect(body.error.details[0].message).toBe("Presidio connection failed");
+ });
+
+ test("includes languageFallback in response", async () => {
+ mockDetectPII.mockResolvedValueOnce([]);
+
+ const res = await app.request("/api/mask", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: "Hello world" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ languageFallback: boolean;
+ };
+ expect(typeof body.languageFallback).toBe("boolean");
+ });
+
+ test("respects multiple entity types in startFrom", async () => {
+ mockDetectPII.mockResolvedValueOnce([
+ { entity_type: "PERSON", start: 0, end: 4, score: 0.9 },
+ { entity_type: "EMAIL_ADDRESS", start: 5, end: 21, score: 0.9 },
+ ]);
+
+ const res = await app.request("/api/mask", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ text: "John john@example.com",
+ startFrom: { PERSON: 3, EMAIL_ADDRESS: 7 },
+ }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ masked: string;
+ counters: Record<string, number>;
+ };
+ expect(body.masked).toContain("[[PERSON_4]]");
+ expect(body.masked).toContain("[[EMAIL_ADDRESS_8]]");
+ expect(body.counters.PERSON).toBe(4);
+ expect(body.counters.EMAIL_ADDRESS).toBe(8);
+ });
+
+ test("skips both detections when detect is empty array", async () => {
+ const res = await app.request("/api/mask", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ text: "john@example.com -----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----",
+ detect: [],
+ }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ masked: string;
+ entities: unknown[];
+ };
+ // Nothing should be masked when detect is empty
+ expect(body.masked).toContain("john@example.com");
+ expect(body.masked).toContain("-----BEGIN RSA PRIVATE KEY-----");
+ expect(body.entities).toHaveLength(0);
+ });
});
counters: Record<string, number>;
entities: MaskEntity[];
language: string;
+ languageFallback: boolean;
}
/**
function extractEntities(
countersBefore: Record<string, number>,
context: PlaceholderContext,
+ isSecret: boolean,
): MaskEntity[] {
const entities: MaskEntity[] = [];
const startCount = countersBefore[type] || 0;
// Add entities for each new placeholder created
for (let i = startCount + 1; i <= count; i++) {
- // Find the placeholder in the mapping
- const placeholder = Object.keys(context.mapping).find((p) => p.includes(`${type}_${i}]`));
- if (placeholder) {
+ // Build placeholder directly using known format
+ const placeholder = isSecret ? `[[SECRET_MASKED_${type}_${i}]]` : `[[${type}_${i}]]`;
+
+ if (context.mapping[placeholder]) {
entities.push({ type, placeholder });
}
}
const countersBefore = { ...context.counters };
const piiResult = maskPII(maskedText, filteredEntities, context);
maskedText = piiResult.masked;
- allEntities.push(...extractEntities(countersBefore, piiResult.context));
+ allEntities.push(...extractEntities(countersBefore, piiResult.context, false));
// Collect unique entity types for logging
for (const entity of filteredEntities) {
error: {
message: "PII detection failed",
type: "detection_error",
- details: error instanceof Error ? error.message : "Unknown error",
+ details: [{ message: error instanceof Error ? error.message : "Unknown error" }],
},
},
503,
const countersBefore = { ...context.counters };
const secretsMaskResult = maskSecrets(maskedText, secretsResult.locations, context);
maskedText = secretsMaskResult.masked;
- allEntities.push(...extractEntities(countersBefore, secretsMaskResult.context));
+ allEntities.push(...extractEntities(countersBefore, secretsMaskResult.context, true));
// Collect unique secret types for logging
for (const match of secretsResult.matches) {
error: {
message: "Secrets detection failed",
type: "detection_error",
- details: error instanceof Error ? error.message : "Unknown error",
+ details: [{ message: error instanceof Error ? error.message : "Unknown error" }],
},
},
503,
counters: { ...context.counters },
entities: allEntities,
language,
+ languageFallback,
};
return c.json(response);