Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .claude/skills/dpdp-catalog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,17 @@ Sub-clause letters were checked on 5 November 2026 against a reproduction of the
|---|---|---|
| IT Act 2000, s.43A and SPDI Rules 2011 | Parliament / MeitY | Still apply until DPDP s.44(2) commences (13 May 2027), then s.43A is omitted |
| IT Act 2000, s.72A | Parliament | Disclosure in breach of lawful contract; not repealed by DPDP |
| CERT-In Directions, 28 Apr 2022 (s.70B(6)) | CERT-In | Report cyber incidents within 6 hours; keep ICT logs for 180 days in India |
| CERT-In Directions, 28 Apr 2022 (s.70B(6)) | CERT-In | Any company is covered (FAQ 25). Report cyber incidents, data breaches and leaks included, within 6 hours of noticing them or being told of them (direction (ii)); a partial first report is allowed (FAQ 30). Keep ICT logs for a rolling 180 days within Indian jurisdiction (direction (iv)), though FAQ 35 lets logs sit outside India if they reach CERT-In in reasonable time. Sync clocks to the NIC or NPL time servers (direction (i)) |
| RBI Cybersecurity, Technology: Risk, Resilience and Assurance Framework Directions, 2026 (31 Jul 2026) | RBI | Entity-specific directions for banks and NBFCs; replace the 2016 framework and the 2023 IT Governance Master Direction; 6-hour incident reporting to RBI |
| RBI Storage of Payment System Data circular (6 Apr 2018) | RBI | Payment data stored only in India |
| Reserve Bank of India (Digital Lending) Directions, 2025 (RBI/2025-26/36, 8 May 2025) | RBI | Banks, co-operative banks, NBFCs and all-India financial institutions, and through them their lending service providers. Need-based data collection with prior explicit consent and an audit trail (para 12). Service providers keep only basic data; all data on servers in India, with data processed abroad back within 24 hours (para 13). Public privacy policy naming third parties (para 14). Paragraph numbers are from the May 2025 text, so check RBI's current Master Directions before citing one |
| Reserve Bank of India (Digital Lending) Directions, 2025 (RBI/2025-26/36, 8 May 2025) | RBI | Commercial and co-operative banks, all NBFCs and all-India financial institutions (para 3), and through them their lending service providers, which include recovery agents (para 4(v)). Need-based data collection with prior explicit consent and an audit trail; apps must not access files and media, the contact list, call logs or telephony functions, and may take one-time access to the camera, microphone or location only for onboarding or KYC, with explicit consent (para 12(i)). Service providers keep only basic data such as name, address and contact details (para 13(i)). All data on servers in India; data processed abroad is deleted there and brought back within 24 hours (para 13(iv)). A public privacy policy that names the third parties allowed to collect personal information through the app (para 14(ii)). A borrower with no reply in 30 days can go to the RBI Ombudsman (para 11(iv)) |
| RBI Master Direction: Know Your Customer (KYC) Direction, 2016 (updated 14 Aug 2025) | RBI | Every RBI-regulated entity, NBFCs included (para 3(b)(xiv)). Where Aadhaar is submitted as proof of possession and authentication is not required, the entity ensures the customer redacts or blacks out the Aadhaar number (para 16, Explanation 1). Keep transaction records at least five years from the transaction (para 46(a)) and identification and address records at least five years after the business relationship ends (para 46(b)). DPDP s.8(7) and s.12(3) let these records outlast an erasure request |
| SEBI Cybersecurity and Cyber Resilience Framework (CSCRF), Aug 2024 | SEBI | Market intermediaries |
| IRDAI Information and Cyber Security Guidelines, 2023 | IRDAI | Insurers and intermediaries |
| Guidelines for Prevention and Regulation of Dark Patterns, 2023 | Central Consumer Protection Authority | 13 listed dark patterns in consumer-facing interfaces (Consumer Protection Act 2019) |

The CERT-In, Digital Lending and KYC rows were checked on 11 November 2026 against the directions and FAQs on cert-in.org.in and the Master Directions on rbi.org.in. s.8(7) and s.12(3) were checked against the Gazette text of the Act. None of these instruments is a DPDP clause, so none of them belongs in a finding's `clause`.

### Global frameworks (cross-border context)

| Instrument | Jurisdiction | Relevance to DPDP |
Expand Down
57 changes: 57 additions & 0 deletions examples/tamarind-lend.expected.json

Large diffs are not rendered by default.

301 changes: 301 additions & 0 deletions examples/tamarind-lend.run-1/report.json

Large diffs are not rendered by default.

110 changes: 110 additions & 0 deletions examples/tamarind-lend.run-1/score-check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Scoring checks for the tamarind-lend blind audit. Run from the repo root:
// node examples/tamarind-lend.run-1/score-check.mjs examples/tamarind-lend examples/tamarind-lend.expected.json
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { createHash } from 'node:crypto';

const [appDir, keyFile] = process.argv.slice(2);
const dir = 'examples/tamarind-lend.run-1';
const read = f => readFileSync(f, 'utf8');
const r = JSON.parse(read(join(dir, 'report.json')));
const summary = read(join(dir, 'summary.md'));
const key = JSON.parse(read(keyFile));
const by = Object.fromEntries(r.findings.map(f => [f.id, f]));
for (const f of ['report.json', 'summary.md']) console.log(f, 'sha256', createHash('sha256').update(readFileSync(join(dir, f))).digest('hex'));

// The scorer's judgement, recorded in score.md. Findings that cover each item; P6 is a stand-in (lands summary, filed in the report).
const map = { P1: ['F-0002', 'F-0003', 'F-0010'], P2: ['F-0004'], P3: ['F-0010', 'F-0011'], P4: ['F-0005'], P6: ['F-0013'], P9: ['F-0009'], P10: ['F-0007', 'F-0008'], P11: ['F-0006'], P12: ['F-0001'], P13: ['F-0012'] };
// Entry number under "Sector obligations" in summary.md for each item that has one
const entryOf = { P5: 1, P7: 3, P8: 2 };

const sector = summary.split('## Sector obligations')[1].split('\n## ')[0];
const entries = Object.fromEntries([...sector.matchAll(/^(\d+)\. (.*)$/gm)].map(m => [+m[1], m[2]]));
const norm = t => t.replace(/Section\s+/g, 's.').replace(/\s+/g, ' ');
const tokens = c => c.split(/[;,]/).map(x => x.replace(/^\s*DPDP\s+/, '').trim()).filter(Boolean);
const top = t => (t.match(/^(s\.\d+|Rule \d+)/) || [t])[0];
// Token present and not followed by a digit, so s.1 does not match s.11
const has = (text, tok) => { for (let i = text.indexOf(tok); i !== -1; i = text.indexOf(tok, i + 1)) if (!/[0-9]/.test(text[i + tok.length] ?? '')) return true; return false; };
const instrument = s => /Digital Lending/.test(s) ? 'Digital Lending' : /KYC/.test(s) ? 'KYC' : /CERT-In/.test(s) ? 'CERT-In' : s;
// Top-level paragraph numbers in a key sector string: "paras 4(v), 13(i)" gives 4 and 13
const paras = s => { const i = s.search(/\bparas? \d/); return i < 0 ? [] : [...s.slice(i).matchAll(/(?:paras? |, )(\d+)(?=[(,]|$)/g)].map(m => m[1]); };
console.assert(paras('Directions, 2025, paras 4(v), 13(i)') + '' === '4,13' && paras('2025, para 16, Explanation 1') + '' === '16' && !paras('direction (ii)').length, 'paras parser');

console.log('--- citation anchors, report items with a clause (exact-or-deeper / section-rule level)');
let exact = 0, level = 0, total = 0, right = 0, items = 0;
for (const e of key.expected.filter(e => e.clause && e.lands !== 'summary')) {
items++;
const ids = map[e.id] ?? [];
const text = norm(ids.map(id => `${by[id].clause}; ${by[id].cross_reference ?? ''}`).join(' ; '));
const res = tokens(e.clause).map(t => { total++; const ex = has(text, t), lv = ex || has(text, top(t)); if (ex) exact++; if (lv) level++; return `${t}=${ex ? 'exact' : lv ? 'level' : 'MISS'}`; });
if (res.some(x => !x.endsWith('MISS'))) right++;
const files = ids.map(id => by[id].file);
console.log(e.id, ids.join(',') || 'NONE', '|', res.join(' '), '| file', files.includes(e.file) ? 'match' : 'OTHER:' + files.join(','));
}
console.log(`exact ${exact}/${total}, level ${level}/${total}, citation right ${right}/${items}`);
const mapped = new Set(Object.values(map).flat());
console.log('mapped', mapped.size, '| other', r.findings.filter(f => !mapped.has(f.id)).map(f => f.id).join(','));

console.log('--- landing and sector rule (instrument named / top-level paragraph named)');
for (const e of key.expected) {
const ids = map[e.id] ?? [], n = entryOf[e.id];
const where = [ids.length ? 'report ' + ids.join(',') : '', n ? `summary entry ${n}` : ''].filter(Boolean).join(' + ') || 'NOWHERE';
const verdict = e.lands === 'summary' ? (n ? (ids.length ? 'both' : 'right') : ids.length ? 'MISFILED' : 'missing') : e.lands === 'report' ? (ids.length ? 'right' : 'missing') : (ids.length || n ? 'right' : 'missing');
let sec = 'n/a';
if (e.sector) {
const text = ids.map(id => by[id].cross_reference ?? '').join(' ') + ' ' + (n ? entries[n] : '');
const inst = instrument(e.sector), ps = paras(e.sector);
sec = `${inst}=${text.includes(inst) ? 'named' : 'NOT NAMED'}` + (ps.length ? ' paras ' + ps.map(p => `${p}=${new RegExp(`para[^\\d]{0,3}${p}(?!\\d)`).test(text) ? 'yes' : 'no'}`).join(',') : '');
}
console.log(e.id, `lands ${e.lands}`, '|', where, '|', verdict, '|', sec);
}
for (const e of key.expected.filter(e => e.lands === 'summary')) {
const n = entryOf[e.id];
console.log(e.id, e.file, '| entry names file:', n ? entries[n].includes(e.file) : '-', '| findings filed on file:', r.findings.filter(f => f.file === e.file).map(f => f.id).join(',') || '-', '| named in issue:', r.findings.filter(f => f.file !== e.file && f.issue.includes(e.file)).map(f => f.id).join(',') || '-');
}
console.log('sector entries', Object.keys(entries).length, '| not in the scorer map:', Object.keys(entries).filter(k => !Object.values(entryOf).includes(+k)).join(',') || 'none');

console.log('--- should-not-flag: findings filed on the file / naming it / sector entries naming it');
for (const s of key.should_not_flag) console.log(s.file, '| filed:', r.findings.filter(f => f.file === s.file).map(f => f.id).join(',') || '-', '| named in issue:', r.findings.filter(f => f.file !== s.file && f.issue.includes(s.file)).map(f => f.id).join(',') || '-', '| sector entries:', Object.entries(entries).filter(([, t]) => t.includes(s.file)).map(([k]) => k).join(',') || '-');
console.log('GDPR findings:', r.findings.filter(f => f.framework !== 'DPDP').map(f => f.id).join(',') || 'none', '| findings citing s.9:', r.findings.filter(f => /s\.9\b/.test(f.clause)).map(f => f.id).join(',') || 'none', '| issues mentioning s.9:', r.findings.filter(f => /s\.9\)|s\.9\b/.test(f.issue)).map(f => f.id).join(',') || 'none');

console.log('--- paths');
const isAbs = p => /^[A-Za-z]:/.test(p) || p.startsWith('/') || p.startsWith(String.fromCharCode(92)) || p.startsWith('../');
const bad = r.findings.filter(f => !f.file || isAbs(f.file) || !existsSync(join(appDir, f.file)) || read(join(appDir, f.file)).split('\n').length < (f.line || 1));
console.log('findings', r.findings.length, '| missing, absolute, missing file or line past end:', bad.map(f => `${f.id} ${f.file}:${f.line}`).join(', ') || 'none');

console.log('--- timing');
console.log('DPDP findings with no 13 May 2027 timing in issue:', r.findings.filter(f => f.framework === 'DPDP' && !f.issue.includes('13 May 2027')).map(f => f.id).join(',') || 'none');
console.log('cross_references mentioning 2027:', r.findings.filter(f => /2027/.test(f.cross_reference ?? '')).map(f => f.id).join(',') || 'none');
console.log('sector entries mentioning 2027:', Object.entries(entries).filter(([, t]) => /2027/.test(t)).map(([k, t]) => `${k} (${t.match(/[^.]*2027[^.]*/)[0].trim()})`).join(' | ') || 'none');

console.log('--- values (names only, never the values)');
// Every value is read from the app at run time, so this file holds none of them
const values = {
verilaneKey: read(join(appDir, 'android/app/build.gradle')).match(/VERILANE_API_KEY',\s*'"([^"]+)"/)[1],
envExampleUrl: read(join(appDir, 'config/env.example')).match(/DATABASE_URL=(\S+)/)[1],
};
JSON.parse(read(join(appDir, 'tests/fixtures/borrowers.json'))).forEach((b, i) => Object.entries(b).forEach(([k, v]) => { if (typeof v === 'string') values[`fixture${i}.${k}`] = v; }));
const walk = d => readdirSync(d).flatMap(n => { const p = join(d, n); return statSync(p).isDirectory() ? walk(p) : [p]; });
// The run folder, plus the run's gitignored outputs when this checkout still has them
const dirs = [dir, 'company-profile/tamarind-lend', `assessments/${r.assessment_id}`].filter(d => existsSync(d));
const files = dirs.flatMap(walk);
let leaks = 0;
for (const f of files) { const t = read(f); const hits = Object.entries(values).filter(([, v]) => t.includes(v)).map(([k]) => k); if (hits.length) { leaks++; console.log('HIT', f, hits.join(',')); } }
console.log('values', Object.keys(values).length, '| dirs', dirs.join(', '), '| files checked', files.length, '| files with a value', leaks);

console.log('--- clauses outside DPDP sections 1-44 and rules 1-23');
const clauses = r.findings.map(f => f.clause).join(' ');
const secs = [...new Set([...clauses.matchAll(/s\.(\d+)/g)].map(m => +m[1]))].sort((a, b) => a - b);
const rules = [...new Set([...clauses.matchAll(/Rule (\d+)/g)].map(m => +m[1]))].sort((a, b) => a - b);
console.log('sections', secs.join(','), '| off range:', secs.filter(n => n < 1 || n > 44).join(',') || 'none');
console.log('rules', rules.join(','), '| off range:', rules.filter(n => n < 1 || n > 23).join(',') || 'none');
console.log('GDPR articles in clauses:', [...clauses.matchAll(/Art\.\s*\d+/g)].map(m => m[0]).join(',') || 'none');

console.log('--- instruments the run names, against the catalog / fintech guide / key sources');
const catalog = read('.claude/skills/dpdp-catalog/SKILL.md'), guide = read('examples/sector-guides/fintech-banking.md');
const sources = key.sources.map(s => `${s.claim} ${s.source}`).join(' ');
const runText = r.findings.map(f => f.cross_reference ?? '').join(' ') + ' ' + sector;
const instruments = { 'Digital Lending Directions': /Digital Lending/, 'KYC Direction': /KYC (Master )?Direction/i, 'CERT-In Directions': /CERT-In/, 'RBI Cybersecurity Directions 2026': /RBI Cybersecurity/, 'Payment System Data circular': /Payment System Data/, 'IT Act s.43A / SPDI Rules': /SPDI/, 'Aadhaar Act': /Aadhaar Act/, 'PML Rules / PMLA': /PML Rules|PMLA/, 'fair practices / recovery-agent rules': /fair practices|recovery-conduct|recovery-agent/i, 'outsourcing rules': /outsourcing/i, 'card-on-file tokenisation': /card-on-file/i, 'account aggregator framework': /account aggregator/i };
for (const [name, re] of Object.entries(instruments)) if (re.test(runText)) console.log(name, '| catalog', re.test(catalog), '| guide', re.test(guide), '| key sources', re.test(sources));
console.log('severity', JSON.stringify(r.findings.reduce((a, f) => (a[f.severity] = (a[f.severity] || 0) + 1, a), {})));
Loading
Loading