@shipi18n/core 2.10.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/formats/android.js +65 -0
- package/src/formats/po.js +17 -6
- package/src/formats/wp-jed.js +130 -0
- package/src/index.js +5 -3
- package/src/secrets.js +103 -0
- package/src/tree.js +81 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipi18n/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"description": "Translation QA for i18n locale files: placeholder and plural validation, key parity, coverage, and an LLM-as-judge semantic review. Also a structure-preserving translation engine — bring your own OpenAI or Anthropic key.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/formats/android.js
CHANGED
|
@@ -67,6 +67,71 @@ function unescapeAndroid(s) {
|
|
|
67
67
|
const isUntranslatable = (el) => el['@_translatable'] === 'false' || el['@_translatable'] === false
|
|
68
68
|
const asArray = (v) => (Array.isArray(v) ? v : v == null ? [] : [v])
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* AAPT string-escaping rules, applied to the RAW text (before backslash
|
|
72
|
+
* unescaping). An apostrophe outside a "…"-quoted span must be `\'`, and every
|
|
73
|
+
* `"` must be balanced or escaped `\"` — an unescaped apostrophe is the classic
|
|
74
|
+
* `values-fr/strings.xml` build breaker ("Apostrophe not preceded by \\").
|
|
75
|
+
* Returns a finding descriptor, or null if the string is clean.
|
|
76
|
+
*/
|
|
77
|
+
function androidEscapingProblem(raw) {
|
|
78
|
+
let inQuote = false
|
|
79
|
+
for (let i = 0; i < raw.length; i++) {
|
|
80
|
+
const c = raw[i]
|
|
81
|
+
if (c === '\\') {
|
|
82
|
+
i++ // the next char is escaped — skip it
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if (c === '"') {
|
|
86
|
+
inQuote = !inQuote
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (c === "'" && !inQuote) {
|
|
90
|
+
return {
|
|
91
|
+
type: 'android-unescaped-apostrophe',
|
|
92
|
+
message: "unescaped apostrophe — Android needs \\' or a \"…\"-wrapped string (AAPT compile error)",
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (inQuote) {
|
|
97
|
+
return {
|
|
98
|
+
type: 'android-unbalanced-quote',
|
|
99
|
+
message: 'unbalanced double-quote — Android needs \\" for a literal quote (AAPT compile error)',
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Escaping findings for one strings.xml document — the errors AAPT would throw
|
|
107
|
+
* that the check engine can't see once the parser has decoded the values.
|
|
108
|
+
* Checked on the raw node text (backslashes/quotes intact). Untranslatable and
|
|
109
|
+
* unnamed entries are skipped, matching parseAndroidStrings.
|
|
110
|
+
* @param {string} xml
|
|
111
|
+
* @returns {Array<{type:string, severity:'error', path:string, message:string}>}
|
|
112
|
+
*/
|
|
113
|
+
export function androidEscapingFindings(xml) {
|
|
114
|
+
const res = parser.parse(xml)?.resources || {}
|
|
115
|
+
const findings = []
|
|
116
|
+
const check = (raw, path) => {
|
|
117
|
+
const p = androidEscapingProblem(String(raw))
|
|
118
|
+
if (p) findings.push({ type: p.type, severity: 'error', path, message: p.message })
|
|
119
|
+
}
|
|
120
|
+
for (const s of res.string || []) {
|
|
121
|
+
if (!s['@_name'] || isUntranslatable(s)) continue
|
|
122
|
+
check(nodeText(s), s['@_name'])
|
|
123
|
+
}
|
|
124
|
+
for (const p of res.plurals || []) {
|
|
125
|
+
if (!p['@_name'] || isUntranslatable(p)) continue
|
|
126
|
+
for (const it of asArray(p.item)) if (it['@_quantity']) check(nodeText(it), `${p['@_name']}[${it['@_quantity']}]`)
|
|
127
|
+
}
|
|
128
|
+
for (const a of res['string-array'] || []) {
|
|
129
|
+
if (!a['@_name'] || isUntranslatable(a)) continue
|
|
130
|
+
asArray(a.item).forEach((it, i) => check(nodeText(it), `${a['@_name']}[${i}]`))
|
|
131
|
+
}
|
|
132
|
+
return findings
|
|
133
|
+
}
|
|
134
|
+
|
|
70
135
|
/**
|
|
71
136
|
* Parse a strings.xml document into a plain locale object.
|
|
72
137
|
* @param {string} xml
|
package/src/formats/po.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { extractPlaceholders } from '../placeholders.js'
|
|
16
16
|
|
|
17
|
-
const CTXT_SEP = '\u0004'
|
|
17
|
+
export const CTXT_SEP = '\u0004'
|
|
18
18
|
|
|
19
19
|
/** Decode the C-style escapes gettext uses in quoted strings. */
|
|
20
20
|
function unescapePo(s) {
|
|
@@ -33,13 +33,13 @@ function npluralsFromHeader(headerMsgstr) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
36
|
+
* Low-level: parse a .po/.pot into raw entries (msgid/msgctxt/msgstr/msgstrs/
|
|
37
|
+
* fuzzy), before any check semantics. Shared by parsePo and the WordPress JED
|
|
38
|
+
* sync check, which needs the plural msgstrs that parsePo folds away.
|
|
37
39
|
* @param {string} text
|
|
38
|
-
* @returns {{
|
|
39
|
-
* source: Record<string,string>, target: Record<string,string>,
|
|
40
|
-
* findings: Array<object> }}
|
|
40
|
+
* @returns {Array<{msgid?:string,msgctxt?:string,msgidPlural?:string,msgstr?:string,msgstrs?:string[],fuzzy?:boolean}>}
|
|
41
41
|
*/
|
|
42
|
-
export function
|
|
42
|
+
export function readPoEntries(text) {
|
|
43
43
|
const lines = text.split(/\r?\n/)
|
|
44
44
|
const entries = []
|
|
45
45
|
let cur = null
|
|
@@ -94,7 +94,18 @@ export function parsePo(text) {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
flush()
|
|
97
|
+
return entries
|
|
98
|
+
}
|
|
97
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Parse a .po/.pot document into source/target/findings for the check engine.
|
|
102
|
+
* @param {string} text
|
|
103
|
+
* @returns {{ language: string|null, nplurals: number|null,
|
|
104
|
+
* source: Record<string,string>, target: Record<string,string>,
|
|
105
|
+
* findings: Array<object> }}
|
|
106
|
+
*/
|
|
107
|
+
export function parsePo(text) {
|
|
108
|
+
const entries = readPoEntries(text)
|
|
98
109
|
const source = Object.create(null) // null-proto: crafted keys can't pollute
|
|
99
110
|
const target = Object.create(null)
|
|
100
111
|
const findings = []
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress `.po` ↔ JED `.json` sync check.
|
|
3
|
+
*
|
|
4
|
+
* Since WP 5.0, JavaScript strings are translated from a JED-format JSON that
|
|
5
|
+
* `wp i18n make-json` GENERATES from the `.po`. The trap: editing the `.po` and
|
|
6
|
+
* forgetting to re-run make-json. The PHP side then shows the new translation
|
|
7
|
+
* while the JS side silently serves the stale one — and nothing in the WP
|
|
8
|
+
* toolchain flags it. This check does.
|
|
9
|
+
*
|
|
10
|
+
* Direction matters. The JED file holds only the strings used in JS, so it is a
|
|
11
|
+
* SUBSET of the `.po`. We therefore validate JED → `.po`:
|
|
12
|
+
* - jed-drift : a string whose JED translation differs from the current `.po`
|
|
13
|
+
* msgstr → the JSON is stale, re-run `wp i18n make-json`.
|
|
14
|
+
* - jed-orphan: a string in the JED that no longer exists in the `.po` → a
|
|
15
|
+
* removed/renamed source string the JSON still references.
|
|
16
|
+
* A `.po` string absent from the JED is NOT flagged — that's a normal PHP-only
|
|
17
|
+
* string, and flagging it would bury the real signal in noise.
|
|
18
|
+
*
|
|
19
|
+
* JED shape (make-json output):
|
|
20
|
+
* { "domain": "messages",
|
|
21
|
+
* "locale_data": { "messages": {
|
|
22
|
+
* "": { "domain": ..., "lang": ..., "plural-forms": ... }, // metadata
|
|
23
|
+
* "Save": ["Guardar"], // singular
|
|
24
|
+
* "ctxtSave": ["Guardar (ctx)"], // + context
|
|
25
|
+
* "%s item": ["%s artículo", "%s artículos"] } } } // plural forms
|
|
26
|
+
*/
|
|
27
|
+
import { readPoEntries, CTXT_SEP } from './po.js'
|
|
28
|
+
|
|
29
|
+
/** A key → translation-forms[] map from a `.po`, matching JED's keying. */
|
|
30
|
+
function poMap(text) {
|
|
31
|
+
const map = Object.create(null)
|
|
32
|
+
for (const e of readPoEntries(text)) {
|
|
33
|
+
if (e.msgid === undefined) continue
|
|
34
|
+
if (e.msgid === '' && e.msgctxt === undefined) continue // header entry
|
|
35
|
+
const key = e.msgctxt !== undefined ? `${e.msgctxt}${CTXT_SEP}${e.msgid}` : e.msgid
|
|
36
|
+
map[key] = e.msgidPlural !== undefined ? e.msgstrs || [] : [e.msgstr ?? '']
|
|
37
|
+
}
|
|
38
|
+
return map
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True if a parsed JSON value looks like a JED locale file. */
|
|
42
|
+
export function isJed(json) {
|
|
43
|
+
return !!json && typeof json === 'object' && !!json.locale_data && typeof json.locale_data === 'object'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Pull the active domain's translation entries out of a JED object. */
|
|
47
|
+
function jedEntries(json) {
|
|
48
|
+
const ld = json.locale_data
|
|
49
|
+
const domains = Object.keys(ld)
|
|
50
|
+
// Prefer the declared domain when present; else the first (make-json emits one).
|
|
51
|
+
const domain = json.domain && ld[json.domain] ? json.domain : domains[0]
|
|
52
|
+
const block = ld[domain]
|
|
53
|
+
if (!block || typeof block !== 'object') throw new Error('JED locale_data has no usable domain block')
|
|
54
|
+
const entries = Object.create(null)
|
|
55
|
+
for (const k of Object.keys(block)) {
|
|
56
|
+
if (k === '') continue // the "" pseudo-entry is metadata, not a string
|
|
57
|
+
const v = block[k]
|
|
58
|
+
entries[k] = Array.isArray(v) ? v : [v] // tolerate a bare string value
|
|
59
|
+
}
|
|
60
|
+
return { domain, entries }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Human-readable form of a possibly context-qualified key. */
|
|
64
|
+
function showKey(key) {
|
|
65
|
+
return key.includes(CTXT_SEP) ? key.split(CTXT_SEP).join(' ⁄ ') : key
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Compare one JED JSON against its source `.po`.
|
|
70
|
+
* @param {string} poText raw .po/.pot text
|
|
71
|
+
* @param {object} jed parsed JED JSON object
|
|
72
|
+
* @returns {{ domain?: string, findings: object[],
|
|
73
|
+
* stats: { checked:number, drift:number, orphan:number,
|
|
74
|
+
* jedEntries:number, poEntries:number } }}
|
|
75
|
+
*/
|
|
76
|
+
export function checkJedSync(poText, jed) {
|
|
77
|
+
if (!isJed(jed)) {
|
|
78
|
+
return {
|
|
79
|
+
findings: [{ type: 'invalid-file', severity: 'error', path: '(jed)', message: 'not a JED file — no locale_data object' }],
|
|
80
|
+
stats: { checked: 0, drift: 0, orphan: 0, jedEntries: 0, poEntries: 0 },
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const po = poMap(poText)
|
|
84
|
+
const { domain, entries } = jedEntries(jed)
|
|
85
|
+
const findings = []
|
|
86
|
+
let checked = 0
|
|
87
|
+
let drift = 0
|
|
88
|
+
let orphan = 0
|
|
89
|
+
|
|
90
|
+
for (const key of Object.keys(entries)) {
|
|
91
|
+
const jedForms = entries[key]
|
|
92
|
+
const poForms = po[key]
|
|
93
|
+
const shown = showKey(key)
|
|
94
|
+
|
|
95
|
+
if (poForms === undefined) {
|
|
96
|
+
orphan++
|
|
97
|
+
findings.push({
|
|
98
|
+
type: 'jed-orphan',
|
|
99
|
+
severity: 'warning',
|
|
100
|
+
path: shown,
|
|
101
|
+
message: 'in the JS JSON but not in the .po — a removed/renamed source string the build still ships',
|
|
102
|
+
})
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
checked++
|
|
107
|
+
const n = Math.max(jedForms.length, poForms.length)
|
|
108
|
+
for (let i = 0; i < n; i++) {
|
|
109
|
+
const jt = jedForms[i] ?? ''
|
|
110
|
+
const pt = poForms[i] ?? ''
|
|
111
|
+
if (jt !== pt) {
|
|
112
|
+
drift++
|
|
113
|
+
findings.push({
|
|
114
|
+
type: 'jed-drift',
|
|
115
|
+
severity: 'error',
|
|
116
|
+
path: n > 1 ? `${shown} [form ${i}]` : shown,
|
|
117
|
+
message: 'JS JSON is out of sync with the .po — re-run `wp i18n make-json`',
|
|
118
|
+
source: pt,
|
|
119
|
+
translation: jt,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
domain,
|
|
127
|
+
findings,
|
|
128
|
+
stats: { checked, drift, orphan, jedEntries: Object.keys(entries).length, poEntries: Object.keys(po).length },
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/index.js
CHANGED
|
@@ -14,12 +14,14 @@ export { translateJSON, translateStrings, flatten, unflatten } from './translate
|
|
|
14
14
|
export { extractPlaceholders, validatePlaceholders } from './placeholders.js'
|
|
15
15
|
export { isICUControl, checkICU } from './icu.js'
|
|
16
16
|
export { checkTranslations } from './check.js'
|
|
17
|
-
export { runCheck, runSemantic, discoverLayout, compileIgnores, statsFrom, aggregateLanguage, SEP } from './tree.js'
|
|
17
|
+
export { runCheck, runSemantic, discoverLayout, compileIgnores, statsFrom, aggregateLanguage, SEP, scanResultSecrets } from './tree.js'
|
|
18
|
+
export { scanSecrets, maskSecret } from './secrets.js'
|
|
18
19
|
export { lockId, lockEntry, lockFinding, emptyLocks, normalizeLocks, LOCKS_VERSION } from './locks.js'
|
|
19
20
|
export { parseArbBundle, arbLangFromFilename, arbLangFromContent, stripArbMetadata } from './formats/arb.js'
|
|
20
21
|
export { parseXcstrings } from './formats/xcstrings.js'
|
|
21
|
-
export { parseAndroidStrings, androidLangFromValuesDir } from './formats/android.js'
|
|
22
|
-
export { parsePo } from './formats/po.js'
|
|
22
|
+
export { parseAndroidStrings, androidLangFromValuesDir, androidEscapingFindings } from './formats/android.js'
|
|
23
|
+
export { parsePo, readPoEntries, CTXT_SEP } from './formats/po.js'
|
|
24
|
+
export { checkJedSync, isJed } from './formats/wp-jed.js'
|
|
23
25
|
export { parseXliff } from './formats/xliff.js'
|
|
24
26
|
export { reviewTranslations, DEFAULT_JUDGE_MODELS, buildReviewPrompt, parseVerdicts, pairHash } from './review.js'
|
|
25
27
|
export { getLanguageName, LANGUAGE_NAMES } from './languages.js'
|
package/src/secrets.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret / PII detection — a pre-flight before `--semantic` ships strings to a
|
|
3
|
+
* third-party LLM, and an opt-in `check` rule.
|
|
4
|
+
*
|
|
5
|
+
* The BYO-LLM privacy story has a hole: a locale string that contains an API key,
|
|
6
|
+
* a private key, or a customer's email/card gets sent verbatim to the model
|
|
7
|
+
* provider the moment you run `--semantic`. No i18n tool guards that send. This
|
|
8
|
+
* does — and because the semantic pass only ever transmits the pairs we scan
|
|
9
|
+
* here, the pre-flight is complete by construction: what gets sent gets scanned.
|
|
10
|
+
*
|
|
11
|
+
* Bias: high precision over recall. False positives that fail a build teach users
|
|
12
|
+
* to add `--severity secret-preflight=off` and stop trusting the tool, so every
|
|
13
|
+
* pattern is prefix- or checksum-anchored (Luhn for cards, known key prefixes),
|
|
14
|
+
* example/test values are filtered out, and phone matching requires a `+` country
|
|
15
|
+
* code. Matches are always MASKED — a finding never echoes the secret it found.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Mask a matched value so it never appears in a finding, log, or report. */
|
|
19
|
+
export function maskSecret(s) {
|
|
20
|
+
if (s.length <= 6) return '•'.repeat(s.length)
|
|
21
|
+
return s.slice(0, 3) + '•'.repeat(Math.min(6, s.length - 5)) + s.slice(-2)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Luhn checksum — filters random digit runs from real card numbers. */
|
|
25
|
+
function luhnValid(digits) {
|
|
26
|
+
let sum = 0
|
|
27
|
+
let dbl = false
|
|
28
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
29
|
+
let d = digits.charCodeAt(i) - 48
|
|
30
|
+
if (d < 0 || d > 9) return false
|
|
31
|
+
if (dbl) {
|
|
32
|
+
d *= 2
|
|
33
|
+
if (d > 9) d -= 9
|
|
34
|
+
}
|
|
35
|
+
sum += d
|
|
36
|
+
dbl = !dbl
|
|
37
|
+
}
|
|
38
|
+
return sum % 10 === 0 && digits.length >= 13
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Placeholder / documentation emails that should not trip the PII detector.
|
|
42
|
+
const EXAMPLE_EMAIL =
|
|
43
|
+
/@(?:example\.(?:com|org|net)|test(?:\.\w+)?|localhost|(?:your|my)?(?:company|domain|email|site)\.\w+|acme\.\w+)$/i
|
|
44
|
+
|
|
45
|
+
// High-confidence secrets first; email/phone (PII) last. Order matters: the first
|
|
46
|
+
// pattern to claim a span wins, so a provider key is never re-reported as a
|
|
47
|
+
// generic token. `re` must be global (we exec in a loop).
|
|
48
|
+
const PATTERNS = [
|
|
49
|
+
{ kind: 'private-key', severity: 'error', re: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/g },
|
|
50
|
+
{ kind: 'aws-access-key', severity: 'error', re: /\b(?:AKIA|ASIA|AGPA|AIDA)[0-9A-Z]{16}\b/g },
|
|
51
|
+
{ kind: 'gcp-api-key', severity: 'error', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
52
|
+
{ kind: 'github-token', severity: 'error', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/g },
|
|
53
|
+
{ kind: 'github-pat', severity: 'error', re: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/g },
|
|
54
|
+
{ kind: 'slack-token', severity: 'error', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
55
|
+
{ kind: 'stripe-key', severity: 'error', re: /\b[rs]k_(?:live|test)_[0-9A-Za-z]{16,}\b/g },
|
|
56
|
+
{ kind: 'anthropic-key', severity: 'error', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
57
|
+
{ kind: 'openai-key', severity: 'error', re: /\bsk-(?:proj-)?[A-Za-z0-9]{20,}\b/g },
|
|
58
|
+
{ kind: 'jwt', severity: 'error', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
|
|
59
|
+
{ kind: 'credit-card', severity: 'error', re: /\b(?:\d[ -]?){13,19}\b/g, luhn: true },
|
|
60
|
+
{
|
|
61
|
+
kind: 'email',
|
|
62
|
+
severity: 'warning',
|
|
63
|
+
re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
|
|
64
|
+
filter: (m) => !EXAMPLE_EMAIL.test(m),
|
|
65
|
+
},
|
|
66
|
+
// Phone: require a leading + country code — a bare 10-digit run is far too
|
|
67
|
+
// ambiguous (IDs, timestamps, quantities) to flag without crying wolf.
|
|
68
|
+
{ kind: 'phone', severity: 'warning', re: /\+\d[\d ().-]{7,}\d/g },
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Scan one string for secrets / PII.
|
|
73
|
+
* @param {string} str
|
|
74
|
+
* @returns {Array<{kind:string, severity:'error'|'warning', masked:string}>}
|
|
75
|
+
* masked values only — never the raw secret.
|
|
76
|
+
*/
|
|
77
|
+
export function scanSecrets(str) {
|
|
78
|
+
if (!str || typeof str !== 'string') return []
|
|
79
|
+
const hits = []
|
|
80
|
+
const claimed = [] // [start,end) spans already attributed to a higher-confidence pattern
|
|
81
|
+
const overlaps = (a, b) => claimed.some(([s, e]) => a < e && b > s)
|
|
82
|
+
|
|
83
|
+
for (const p of PATTERNS) {
|
|
84
|
+
p.re.lastIndex = 0
|
|
85
|
+
let m
|
|
86
|
+
while ((m = p.re.exec(str)) !== null) {
|
|
87
|
+
const val = m[0]
|
|
88
|
+
if (val === '') {
|
|
89
|
+
p.re.lastIndex++
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
const start = m.index
|
|
93
|
+
const end = start + val.length
|
|
94
|
+
if (overlaps(start, end)) continue
|
|
95
|
+
if (p.filter && !p.filter(val)) continue
|
|
96
|
+
if (p.luhn && !luhnValid(val.replace(/\D/g, ''))) continue
|
|
97
|
+
claimed.push([start, end])
|
|
98
|
+
hits.push({ kind: p.kind, severity: p.severity, masked: maskSecret(val) })
|
|
99
|
+
if (hits.length >= 50) return hits // pathological input backstop
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return hits
|
|
103
|
+
}
|
package/src/tree.js
CHANGED
|
@@ -14,9 +14,10 @@ import { checkTranslations } from './check.js'
|
|
|
14
14
|
import { parse as parseYaml } from 'yaml'
|
|
15
15
|
import { parseArbBundle } from './formats/arb.js'
|
|
16
16
|
import { parseXcstrings } from './formats/xcstrings.js'
|
|
17
|
-
import { parseAndroidStrings, androidLangFromValuesDir } from './formats/android.js'
|
|
17
|
+
import { parseAndroidStrings, androidLangFromValuesDir, androidEscapingFindings } from './formats/android.js'
|
|
18
18
|
import { parsePo } from './formats/po.js'
|
|
19
19
|
import { parseXliff } from './formats/xliff.js'
|
|
20
|
+
import { scanSecrets } from './secrets.js'
|
|
20
21
|
|
|
21
22
|
// Locale files are JSON or YAML. The check logic is format-agnostic once the
|
|
22
23
|
// file is parsed to an object, so support is entirely a parse + discovery
|
|
@@ -316,15 +317,19 @@ export function androidStringsMode({ input, source, isIgnored, glossary }) {
|
|
|
316
317
|
const file = join(dir, d, 'strings.xml')
|
|
317
318
|
const ns = 'strings'
|
|
318
319
|
let data
|
|
320
|
+
let rawXml
|
|
319
321
|
try {
|
|
320
|
-
|
|
322
|
+
rawXml = readTextCapped(file)
|
|
323
|
+
data = parseAndroidStrings(rawXml)
|
|
321
324
|
} catch (err) {
|
|
322
325
|
// A malformed/malicious target (bad XML, external entities, …) is a finding, not a crash.
|
|
323
326
|
const findings = [{ type: 'invalid-file', severity: 'error', path: ns, message: `could not parse ${rel(file)}: ${err.message}` }]
|
|
324
327
|
languages.push(aggregateLanguage(lang, [{ ns, file: rel(file), findings, stats: statsFrom(findings, 0, 0) }]))
|
|
325
328
|
continue
|
|
326
329
|
}
|
|
327
|
-
const { findings, stats } = checkTranslations({ source: sourceData, target: data, targetLang: lang, glossary })
|
|
330
|
+
const { findings: engineFindings, stats } = checkTranslations({ source: sourceData, target: data, targetLang: lang, glossary })
|
|
331
|
+
// Escaping errors AAPT would throw but the decoded values can't reveal.
|
|
332
|
+
const findings = [...engineFindings, ...androidEscapingFindings(rawXml)]
|
|
328
333
|
const kept = findings.filter((f) => !isIgnored(ns, f.path))
|
|
329
334
|
addPairs(perLang, lang, ns, sourceData, data, isIgnored)
|
|
330
335
|
languages.push(
|
|
@@ -508,7 +513,7 @@ export function runCheck({ input, source = 'en', ignoreKeys, glossary, locks } =
|
|
|
508
513
|
*/
|
|
509
514
|
|
|
510
515
|
export async function runSemantic(result, { provider, apiKey, model, baseURL, passes, glossary, cache, fail = false }) {
|
|
511
|
-
const totals = { judged: 0, cached: 0, flagged: 0, calls: 0, parseFailures: 0, excluded: 0 }
|
|
516
|
+
const totals = { judged: 0, cached: 0, flagged: 0, calls: 0, parseFailures: 0, excluded: 0, redacted: 0 }
|
|
512
517
|
|
|
513
518
|
for (const l of result.languages) {
|
|
514
519
|
const pairs = result.semanticPairs?.[l.lang]
|
|
@@ -526,31 +531,50 @@ export async function runSemantic(result, { provider, apiKey, model, baseURL, pa
|
|
|
526
531
|
totals.excluded++
|
|
527
532
|
continue
|
|
528
533
|
}
|
|
534
|
+
// Privacy pre-flight: never transmit a pair carrying a secret/PII to the
|
|
535
|
+
// LLM. Withhold it (fail-closed) and record a finding instead of sending.
|
|
536
|
+
const leaks = [...scanSecrets(pairs.source[key]), ...scanSecrets(pairs.target[key])]
|
|
537
|
+
if (leaks.length) {
|
|
538
|
+
totals.redacted++
|
|
539
|
+
const sepAt = key.indexOf(SEP)
|
|
540
|
+
const nsEntry = l.namespaces.find((n) => n.ns === key.slice(0, sepAt))
|
|
541
|
+
if (nsEntry) {
|
|
542
|
+
nsEntry.findings.push({
|
|
543
|
+
type: 'secret-preflight',
|
|
544
|
+
severity: leaks.some((h) => h.severity === 'error') ? 'error' : 'warning',
|
|
545
|
+
path: key.slice(sepAt + 1),
|
|
546
|
+
message: `withheld from the LLM — detected ${[...new Set(leaks.map((h) => h.kind))].join(', ')}`,
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
continue
|
|
550
|
+
}
|
|
529
551
|
src[key] = pairs.source[key]
|
|
530
552
|
tgt[key] = pairs.target[key]
|
|
531
553
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
for (const k of Object.keys(stats)) totals[k] = (totals[k] ?? 0) + (stats[k] ?? 0)
|
|
539
|
-
|
|
540
|
-
for (const f of findings) {
|
|
541
|
-
const sepAt = f.path.indexOf(SEP)
|
|
542
|
-
const ns = f.path.slice(0, sepAt)
|
|
543
|
-
const path = f.path.slice(sepAt + 1)
|
|
544
|
-
const nsEntry = l.namespaces.find((n) => n.ns === ns)
|
|
545
|
-
if (!nsEntry) continue
|
|
546
|
-
nsEntry.findings.push({
|
|
547
|
-
type: `semantic-${f.category}`,
|
|
548
|
-
severity: fail ? 'error' : 'warning',
|
|
549
|
-
path,
|
|
550
|
-
message: f.note || f.category,
|
|
551
|
-
source: f.source,
|
|
552
|
-
translation: f.translation,
|
|
554
|
+
// Even when nothing is left to send (all pairs excluded or withheld), the
|
|
555
|
+
// pre-flight may have added secret findings, so still recompute stats below.
|
|
556
|
+
if (Object.keys(src).length) {
|
|
557
|
+
const { findings, stats } = await reviewTranslations({
|
|
558
|
+
source: src, target: tgt, from: result.source, to: l.lang,
|
|
559
|
+
provider, apiKey, model, baseURL, passes, glossary, cache,
|
|
553
560
|
})
|
|
561
|
+
for (const k of Object.keys(stats)) totals[k] = (totals[k] ?? 0) + (stats[k] ?? 0)
|
|
562
|
+
|
|
563
|
+
for (const f of findings) {
|
|
564
|
+
const sepAt = f.path.indexOf(SEP)
|
|
565
|
+
const ns = f.path.slice(0, sepAt)
|
|
566
|
+
const path = f.path.slice(sepAt + 1)
|
|
567
|
+
const nsEntry = l.namespaces.find((n) => n.ns === ns)
|
|
568
|
+
if (!nsEntry) continue
|
|
569
|
+
nsEntry.findings.push({
|
|
570
|
+
type: `semantic-${f.category}`,
|
|
571
|
+
severity: fail ? 'error' : 'warning',
|
|
572
|
+
path,
|
|
573
|
+
message: f.note || f.category,
|
|
574
|
+
source: f.source,
|
|
575
|
+
translation: f.translation,
|
|
576
|
+
})
|
|
577
|
+
}
|
|
554
578
|
}
|
|
555
579
|
for (const n of l.namespaces) n.stats = statsFrom(n.findings, n.stats.sourceKeys, n.stats.targetKeys)
|
|
556
580
|
const re = aggregateLanguage(l.lang, l.namespaces)
|
|
@@ -560,3 +584,35 @@ export async function runSemantic(result, { provider, apiKey, model, baseURL, pa
|
|
|
560
584
|
return totals
|
|
561
585
|
}
|
|
562
586
|
|
|
587
|
+
/**
|
|
588
|
+
* Opt-in standalone scan: flag secrets/PII sitting in the locale strings
|
|
589
|
+
* themselves (a leak regardless of any LLM). Walks the same string set the
|
|
590
|
+
* semantic pass would send, attaches `secret-detected` findings, recomputes
|
|
591
|
+
* stats/totals, and returns the number of strings flagged.
|
|
592
|
+
*/
|
|
593
|
+
export function scanResultSecrets(result) {
|
|
594
|
+
let flagged = 0
|
|
595
|
+
for (const l of result.languages) {
|
|
596
|
+
const pairs = result.semanticPairs?.[l.lang]
|
|
597
|
+
if (!pairs) continue
|
|
598
|
+
for (const key of Object.keys(pairs.target || {})) {
|
|
599
|
+
const hits = [...scanSecrets(pairs.source?.[key]), ...scanSecrets(pairs.target[key])]
|
|
600
|
+
if (!hits.length) continue
|
|
601
|
+
flagged++
|
|
602
|
+
const sepAt = key.indexOf(SEP)
|
|
603
|
+
const nsEntry = l.namespaces.find((n) => n.ns === key.slice(0, sepAt))
|
|
604
|
+
if (!nsEntry) continue
|
|
605
|
+
nsEntry.findings.push({
|
|
606
|
+
type: 'secret-detected',
|
|
607
|
+
severity: hits.some((h) => h.severity === 'error') ? 'error' : 'warning',
|
|
608
|
+
path: key.slice(sepAt + 1),
|
|
609
|
+
message: `possible secret/PII in a locale string: ${[...new Set(hits.map((h) => h.kind))].join(', ')}`,
|
|
610
|
+
})
|
|
611
|
+
}
|
|
612
|
+
for (const n of l.namespaces) n.stats = statsFrom(n.findings, n.stats.sourceKeys, n.stats.targetKeys)
|
|
613
|
+
l.stats = aggregateLanguage(l.lang, l.namespaces).stats
|
|
614
|
+
}
|
|
615
|
+
recomputeTotals(result)
|
|
616
|
+
return flagged
|
|
617
|
+
}
|
|
618
|
+
|