security(scratchpad): add R17 Firestore at-rest and negative-access probe scripts
This commit is contained in:
parent
33ea862934
commit
38ff166598
|
|
@ -0,0 +1,59 @@
|
||||||
|
// D1 at-rest ciphertext verifier (READ-ONLY). Prints only field -> enc✓ / NOT-ENC + length.
|
||||||
|
// NEVER prints decrypted/plaintext content (per the QA logging rule).
|
||||||
|
const admin = require('firebase-admin')
|
||||||
|
const path = require('path')
|
||||||
|
const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
|
||||||
|
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
|
||||||
|
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
|
||||||
|
const db = admin.firestore()
|
||||||
|
|
||||||
|
const CIPHER = /^(enc:v1:|sealed:v1:)/
|
||||||
|
// fields that MUST be ciphertext if present
|
||||||
|
const PRIVATE_FIELDS = ['text','content','lastMessagePreview','title','description','duration','note','message','answer','body','preview']
|
||||||
|
|
||||||
|
function checkVal(v) {
|
||||||
|
if (typeof v !== 'string') return null
|
||||||
|
if (CIPHER.test(v)) return `enc✓(${v.length})`
|
||||||
|
// base64 Tink media or short metadata? flag anything that isn't enc/sealed
|
||||||
|
return `NOT-ENC?(len=${v.length})`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sampleColl(ref, label, limit = 3) {
|
||||||
|
try {
|
||||||
|
const snap = await ref.limit(limit).get()
|
||||||
|
if (snap.empty) { console.log(` [${label}] empty`); return }
|
||||||
|
let i = 0
|
||||||
|
snap.forEach(doc => {
|
||||||
|
if (i++ >= limit) return
|
||||||
|
const d = doc.data()
|
||||||
|
const hits = []
|
||||||
|
for (const f of PRIVATE_FIELDS) {
|
||||||
|
if (d[f] !== undefined) hits.push(`${f}=${checkVal(d[f])}`)
|
||||||
|
}
|
||||||
|
console.log(` [${label}] ${doc.id}: ${hits.length ? hits.join(' ') : '(no private fields)'}`)
|
||||||
|
})
|
||||||
|
} catch (e) { console.log(` [${label}] ERR ${e.code || e.message}`) }
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('=== D1 at-rest check, couple', COUPLE, '===')
|
||||||
|
// 1) couple doc subcollections
|
||||||
|
const coupleRef = db.collection('couples').doc(COUPLE)
|
||||||
|
const subs = await coupleRef.listCollections().catch(() => [])
|
||||||
|
console.log('couple subcollections:', subs.map(s => s.id).join(', ') || '(none)')
|
||||||
|
for (const s of subs) await sampleColl(s, `couples/${COUPLE}/${s.id}`)
|
||||||
|
// 2) common top-level collections keyed by coupleId
|
||||||
|
for (const c of ['messages','conversations','answers','bucket_list','date_plans','date_swipes','memory_capsules','capsules','date_plan_preferences','game_sessions']) {
|
||||||
|
const ref = db.collection(c).where('coupleId','==',COUPLE)
|
||||||
|
await sampleColl(ref, c)
|
||||||
|
}
|
||||||
|
// 3) conversations -> messages (nested)
|
||||||
|
try {
|
||||||
|
const convs = await db.collection('conversations').where('coupleId','==',COUPLE).limit(2).get()
|
||||||
|
for (const cv of convs.docs) {
|
||||||
|
await sampleColl(cv.ref.collection('messages'), `conversations/${cv.id}/messages`)
|
||||||
|
}
|
||||||
|
} catch (e) { console.log(' conv->messages ERR', e.code || e.message) }
|
||||||
|
console.log('=== done ===')
|
||||||
|
process.exit(0)
|
||||||
|
})().catch(e => { console.error('FATAL', e); process.exit(1) })
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
// D1 deep probe: dump FIELD NAMES + per-string enc-status/length (NEVER values).
|
||||||
|
const admin = require('firebase-admin')
|
||||||
|
const path = require('path')
|
||||||
|
const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
|
||||||
|
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
|
||||||
|
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
|
||||||
|
const db = admin.firestore()
|
||||||
|
const CIPHER = /^(enc:v1:|sealed:v1:)/
|
||||||
|
const MEDIA = /^(01|AAAA|CiQ|Ei)/ // tink/base64-ish prefixes seen for media
|
||||||
|
|
||||||
|
function fieldReport(d) {
|
||||||
|
return Object.entries(d).map(([k, v]) => {
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const tag = CIPHER.test(v) ? 'enc✓' : (v.length > 40 ? `RAWSTR?(${v.length})` : `meta(${v.length})`)
|
||||||
|
return `${k}:${tag}`
|
||||||
|
}
|
||||||
|
if (Array.isArray(v)) return `${k}:[${v.length}]${v.every(x => typeof x === 'string' && CIPHER.test(x)) && v.length ? 'enc✓' : ''}`
|
||||||
|
if (v && typeof v === 'object') return `${k}:{${Object.keys(v).join(',')}}`
|
||||||
|
return `${k}:${typeof v}`
|
||||||
|
}).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dump(ref, label, n = 2) {
|
||||||
|
try {
|
||||||
|
const s = await ref.limit(n).get()
|
||||||
|
if (s.empty) return console.log(`[${label}] empty`)
|
||||||
|
s.docs.slice(0, n).forEach(doc => console.log(`[${label}] ${doc.id}\n ${fieldReport(doc.data())}`))
|
||||||
|
} catch (e) { console.log(`[${label}] ERR ${e.code || e.message}`) }
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const c = db.collection('couples').doc(COUPLE)
|
||||||
|
// messages nested under each conversation
|
||||||
|
const convs = await c.collection('conversations').limit(3).get()
|
||||||
|
for (const cv of convs.docs) {
|
||||||
|
await dump(cv.ref.collection('messages'), `conv:${cv.id}/messages`, 2)
|
||||||
|
}
|
||||||
|
for (const g of ['this_or_that','desire_sync','how_well','wheel','date_swipes','sessions']) {
|
||||||
|
await dump(c.collection(g), g, 1)
|
||||||
|
}
|
||||||
|
process.exit(0)
|
||||||
|
})().catch(e => { console.error('FATAL', e); process.exit(1) })
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// D1 nested-answer probe: for game answers map, report each uid's answer value enc-status (NEVER values).
|
||||||
|
const admin = require('firebase-admin')
|
||||||
|
const path = require('path')
|
||||||
|
const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
|
||||||
|
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
|
||||||
|
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
|
||||||
|
const db = admin.firestore()
|
||||||
|
const CIPHER = /^(enc:v1:|sealed:v1:)/
|
||||||
|
|
||||||
|
function classify(v) {
|
||||||
|
if (typeof v === 'string') return CIPHER.test(v) ? `enc✓(${v.length})` : `RAW?(${v.length})`
|
||||||
|
if (Array.isArray(v)) return `[${v.length}: ${v.map(classify).join(',')}]`
|
||||||
|
if (v && typeof v === 'object') return '{' + Object.entries(v).map(([k, x]) => `${k}=${classify(x)}`).join(' ') + '}'
|
||||||
|
return typeof v
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(coll, mapField) {
|
||||||
|
const c = db.collection('couples').doc(COUPLE)
|
||||||
|
const s = await c.collection(coll).limit(1).get()
|
||||||
|
if (s.empty) return console.log(`[${coll}] empty`)
|
||||||
|
const d = s.docs[0].data()
|
||||||
|
const m = d[mapField]
|
||||||
|
if (!m) return console.log(`[${coll}] no ${mapField}`)
|
||||||
|
for (const [uid, val] of Object.entries(m)) {
|
||||||
|
console.log(`[${coll}] ${mapField}.${uid.slice(0, 6)}…: ${classify(val)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for (const g of ['this_or_that', 'desire_sync', 'how_well', 'wheel']) await probe(g, 'answers')
|
||||||
|
await probe('date_swipes', 'actions')
|
||||||
|
process.exit(0)
|
||||||
|
})().catch(e => { console.error('FATAL', e); process.exit(1) })
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
// D3 negative access (LIVE): mint a NON-MEMBER token → raw Firestore REST → expect 403/PERMISSION_DENIED.
|
||||||
|
const admin = require('firebase-admin')
|
||||||
|
const path = require('path')
|
||||||
|
const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
|
||||||
|
const PROJECT = 'closer-app-22014'
|
||||||
|
const APIKEY = 'AIzaSyDAD7FnEYzhMsil41SzJ1XMjUNnJWmjie8'
|
||||||
|
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
|
||||||
|
const FAKE_UID = 'qa-nonmember-' + Date.now()
|
||||||
|
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
|
||||||
|
|
||||||
|
const docBase = `https://firestore.googleapis.com/v1/projects/${PROJECT}/databases/(default)/documents`
|
||||||
|
|
||||||
|
async function getIdToken() {
|
||||||
|
const custom = await admin.auth().createCustomToken(FAKE_UID)
|
||||||
|
const r = await fetch(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${APIKEY}`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: custom, returnSecureToken: true })
|
||||||
|
})
|
||||||
|
const j = await r.json()
|
||||||
|
if (!j.idToken) throw new Error('no idToken: ' + JSON.stringify(j))
|
||||||
|
return j.idToken
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryGet(token, p, label) {
|
||||||
|
const r = await fetch(`${docBase}/${p}`, { headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
const ok = r.status === 200
|
||||||
|
const verdict = ok ? '❌❌ LEAK (200)' : `✅ DENIED (${r.status})`
|
||||||
|
console.log(` ${label}: ${verdict} [${p}]`)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
async function trySelfGrant(token, uid) {
|
||||||
|
const r = await fetch(`${docBase}/users/${uid}/entitlements/premium?updateMask.fieldPaths=active`, {
|
||||||
|
method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fields: { active: { booleanValue: true } } })
|
||||||
|
})
|
||||||
|
console.log(` self-grant premium: ${r.status === 200 ? '❌❌ ALLOWED (200)' : `✅ DENIED (${r.status})`}`)
|
||||||
|
return r.status === 200
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('=== D3 non-member negative access (uid', FAKE_UID, ') ===')
|
||||||
|
const tok = await getIdToken()
|
||||||
|
let leaks = 0
|
||||||
|
if (await tryGet(tok, `couples/${COUPLE}`, 'read couple doc')) leaks++
|
||||||
|
if (await tryGet(tok, `couples/${COUPLE}/conversations/main/messages`, 'list messages')) leaks++
|
||||||
|
if (await tryGet(tok, `couples/${COUPLE}/capsules`, 'list capsules')) leaks++
|
||||||
|
if (await tryGet(tok, `couples/${COUPLE}/desire_sync`, 'list desire_sync')) leaks++
|
||||||
|
if (await trySelfGrant(tok, FAKE_UID)) leaks++
|
||||||
|
// cleanup the throwaway auth user
|
||||||
|
await admin.auth().deleteUser(FAKE_UID).catch(() => {})
|
||||||
|
console.log(leaks === 0 ? '=== D3 PASS: all denied ===' : `=== D3 FAIL: ${leaks} LEAK(S) — P0 ===`)
|
||||||
|
process.exit(leaks === 0 ? 0 : 1)
|
||||||
|
})().catch(e => { console.error('FATAL', e.message); process.exit(2) })
|
||||||
Loading…
Reference in New Issue