fini-proof 0.3.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.
@@ -0,0 +1,43 @@
1
+ // PROVENANCE — ported, not rewritten.
2
+ // source : finipe/scripts/ci/check-entrypoint-guard.mjs (ESM entrypoint fail-open detector)
3
+ // commit : finipe 4ac53b367b348d23d000629cb14e19633beb9613 (file last changed in 04152854b)
4
+ // kept : rawGuards() and its helper constants verbatim (lines 60-103 of the source, minus the Finipe
5
+ // CANONICAL_BLOCK text, which is replaced by an empty sentinel: a customer repo has no canonical
6
+ // block, so every argv[1]-vs-own-path compare that is not realpath'd on both sides is reported).
7
+ // changed: the Finipe-only "must carry the byte-identical canonical block" rule and the symlink probe of every
8
+ // guarded file are not ported (running a customer's scripts is out of scope for a static v0.1 check);
9
+ // the symlink repro survives as this engine's behavioural negative control.
10
+ const CANONICAL_BLOCK = '\u0000fini-proof-no-canonical-block\u0000'
11
+ // Built by concatenation so that no source line of THIS file is a raw guard.
12
+ const ARGV1 = 'process.argv' + '[1]'
13
+ const SELF_REFS = ['import.meta.url', '__filename', 'fileURLToPath', 'pathToFileURL']
14
+ const isComment = (t) => t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')
15
+
16
+ /** Raw guard findings in one source text (canonical blocks blanked first, line numbers kept). Pure. */
17
+ export function rawGuards(src) {
18
+ const blank = '\n'.repeat(CANONICAL_BLOCK.split('\n').length - 1)
19
+ const lines = src.split(CANONICAL_BLOCK).join(blank).split('\n')
20
+ const out = []
21
+ for (let i = 0; i < lines.length; i++) {
22
+ const t = lines[i].trim()
23
+ if (!t.includes(ARGV1) || isComment(t)) continue
24
+ const win = lines.slice(Math.max(0, i - 2), i + 3).filter((l) => !isComment(l.trim())).join('\n')
25
+ if (win.includes('===') && SELF_REFS.some((r) => win.includes(r))) out.push({ line: i + 1, text: t.slice(0, 140) })
26
+ }
27
+ // Indirection: `const entry = process.argv[1]` … `path.resolve(entry) === fileURLToPath(import.meta.url)`
28
+ // any number of lines later (scripts/codemods/aggressive-token-enforcement.mjs had exactly this).
29
+ const aliases = new Set()
30
+ for (const l of lines) {
31
+ const m = !isComment(l.trim()) && l.includes(ARGV1) && /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/.exec(l)
32
+ if (m) aliases.add(m[1])
33
+ }
34
+ for (const a of aliases) {
35
+ const re = new RegExp(`\\b${a.replace(/\$/g, '\\$')}\\b`)
36
+ lines.forEach((l, i) => {
37
+ const t = l.trim()
38
+ if (isComment(t) || t.includes(ARGV1) || !re.test(t)) return
39
+ if (t.includes('===') && SELF_REFS.some((r) => t.includes(r)) && !out.some((o) => o.line === i + 1)) out.push({ line: i + 1, text: t.slice(0, 140) })
40
+ })
41
+ }
42
+ return out
43
+ }
@@ -0,0 +1,127 @@
1
+ // PORTED from finipe scripts/ci/lib/migration-invariants.cjs (blob 6440772a, last changed 18ce3086b 2026-09-26,
2
+ // read at finipe commit 4ac53b367b348d23d000629cb14e19633beb9613).
3
+ // Lines 33–135 (LIT, ROUTE, balanced, literalArray, constArray, constString, classesOf, expandStatic) are VERBATIM,
4
+ // converted from CommonJS to ESM exports. expandStatic() expands the statically-resolvable loops of a migration
5
+ // (`for (const t of ['a','b']) { … ${t} … }`) so table-level rules see every table; what stays interpolated is
6
+ // reported as `unresolved` — never read as clean.
7
+ // downReversal() is ADAPTED from evaluateMigration() lines 213–230 (down() must drop what up() created, in
8
+ // reverse order): the Finipe RLS / GRANT / tenant_id-TEXT / finipe_system rules are Finipe-schema-specific and
9
+ // were NOT ported; the problem strings are returned as { rule, table(s) } objects instead of text.
10
+ const LIT = /^\s*(['"`])([A-Za-z0-9_.]+)\1\s*$/;
11
+
12
+ /** The DB-backed tier gate that measures each property class; null = none does. */
13
+ const ROUTE = {
14
+ 'force-rls': 'lint:force-rls-coverage',
15
+ grants: 'lint:grants-coverage',
16
+ 'policy-shape': 'lint:finipe-system-policy-shape',
17
+ 'down-drop': null, // migration:test:roundtrip is continue-on-error in the tier — not a gate
18
+ 'create-table': null, // tenant_id shape has no DB gate
19
+ };
20
+
21
+ function balanced(text, openIdx, open, close) {
22
+ let depth = 0;
23
+ for (let j = openIdx; j < text.length; j += 1) {
24
+ if (text[j] === open) depth += 1;
25
+ else if (text[j] === close) {
26
+ depth -= 1;
27
+ if (depth === 0) return j;
28
+ }
29
+ }
30
+ return -1;
31
+ }
32
+
33
+ /** `[ 'a', "b", `c`, ] as const` -> ['a','b','c'], or null if any element is not a plain literal. */
34
+ function literalArray(inner) {
35
+ const parts = inner.split(',').map((p) => p.trim()).filter((p) => p.length);
36
+ if (!parts.length) return null;
37
+ const out = [];
38
+ for (const p of parts) {
39
+ const m = LIT.exec(p);
40
+ if (!m) return null;
41
+ out.push(m[2]);
42
+ }
43
+ return out;
44
+ }
45
+
46
+ function constArray(src, name) {
47
+ const re = new RegExp(`\\bconst\\s+${name}\\s*(?::[^=]+)?=\\s*\\[`, 'g');
48
+ const m = re.exec(src);
49
+ if (!m) return null;
50
+ const open = m.index + m[0].length - 1;
51
+ const close = balanced(src, open, '[', ']');
52
+ return close < 0 ? null : literalArray(src.slice(open + 1, close));
53
+ }
54
+
55
+ function constString(src, name) {
56
+ const m = new RegExp(`\\bconst\\s+${name}\\s*(?::\\s*string\\s*)?=\\s*(['"\`])([A-Za-z0-9_.]+)\\1\\s*;`).exec(src);
57
+ return m ? m[2] : null;
58
+ }
59
+
60
+ function classesOf(sqlTemplate) {
61
+ const c = [];
62
+ if (/ROW\s+LEVEL\s+SECURITY/i.test(sqlTemplate)) c.push('force-rls');
63
+ if (/\bGRANT\b/i.test(sqlTemplate)) c.push('grants');
64
+ if (/CREATE\s+POLICY/i.test(sqlTemplate)) c.push('policy-shape');
65
+ if (/DROP\s+TABLE/i.test(sqlTemplate)) c.push('down-drop');
66
+ if (/CREATE\s+TABLE/i.test(sqlTemplate)) c.push('create-table');
67
+ return c;
68
+ }
69
+
70
+ function expandStatic(execText) {
71
+ let text = execText;
72
+ // Loops, LAST first: an inner loop starts after its outer one, so it is expanded before it.
73
+ const heads = [...text.matchAll(/\bfor\s*\(\s*const\s+([A-Za-z_$][\w$]*)\s+of\s+/g)].reverse();
74
+ for (const h of heads) {
75
+ const v = h[1];
76
+ let i = h.index + h[0].length;
77
+ let items = null;
78
+ let afterIter;
79
+ if (text[i] === '[') {
80
+ const close = balanced(text, i, '[', ']');
81
+ if (close < 0) continue;
82
+ items = literalArray(text.slice(i + 1, close));
83
+ afterIter = close + 1;
84
+ } else {
85
+ const id = /^([A-Za-z_$][\w$]*)/.exec(text.slice(i));
86
+ if (!id) continue;
87
+ items = constArray(execText, id[1]);
88
+ afterIter = i + id[1].length;
89
+ }
90
+ const paren = /^\s*(?:as\s+const\s*)?\)\s*\{/.exec(text.slice(afterIter));
91
+ if (!paren) continue;
92
+ const bodyOpen = afterIter + paren[0].length - 1;
93
+ const bodyClose = balanced(text, bodyOpen, '{', '}');
94
+ if (bodyClose < 0 || !items) continue; // left in place; the unresolved scan below names it
95
+ const body = text.slice(bodyOpen + 1, bodyClose);
96
+ const iv = new RegExp(`\\$\\{\\s*${v.replace(/\$/g, '\\$')}\\s*\\}`, 'g');
97
+ const expanded = items.map((it) => body.replace(iv, it)).join('\n');
98
+ text = text.slice(0, h.index) + expanded + text.slice(bodyClose + 1);
99
+ }
100
+ // Same-file string constants.
101
+ text = text.replace(/\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g, (all, name) => constString(execText, name) ?? all);
102
+ // Whatever is still interpolated inside an RLS-relevant SQL template.
103
+ const unresolved = [];
104
+ for (const m of text.matchAll(/\$\{([^}]*)\}/g)) {
105
+ const start = text.lastIndexOf('`', m.index);
106
+ const end = text.indexOf('`', m.index);
107
+ const tpl = text.slice(start < 0 ? 0 : start, end < 0 ? text.length : end);
108
+ const classes = classesOf(tpl);
109
+ if (classes.length) unresolved.push({ expr: m[1].trim(), classes });
110
+ }
111
+ return { text, unresolved };
112
+ }
113
+
114
+ /** Adapted from evaluateMigration(): creates = tables up() creates, drops = tables down() drops (in order). */
115
+ function downReversal(creates, drops, sharedTableCounts = {}) {
116
+ const problems = []
117
+ const owned = creates.filter((t) => (sharedTableCounts[t] || 1) <= 1)
118
+ if (owned.length && !drops.length) { problems.push({ rule: 'DOWN_INCOMPLETE', tables: owned }); return problems }
119
+ const missing = owned.filter((t) => !drops.includes(t))
120
+ if (missing.length) problems.push({ rule: 'DOWN_INCOMPLETE', tables: missing })
121
+ const expected = [...owned].reverse().filter((t) => drops.includes(t))
122
+ const actual = drops.filter((t) => owned.includes(t))
123
+ if (expected.length > 1 && expected.join(',') !== actual.join(',')) problems.push({ rule: 'DOWN_ORDER', expected, actual })
124
+ return problems
125
+ }
126
+
127
+ export { LIT, ROUTE, balanced, literalArray, constArray, constString, classesOf, expandStatic, downReversal }
@@ -0,0 +1,123 @@
1
+ // PROVENANCE — ported, not rewritten.
2
+ // source : finipe/scripts/ci/check-secrets-and-log-redaction.mjs, part (a) "no secret material in the tree"
3
+ // commit : finipe 4ac53b367b348d23d000629cb14e19633beb9613 (file last changed in ae240e49e)
4
+ // kept : lines 62-129 and 157-200 verbatim (patterns, .env credential-key segmentation, placeholder rules,
5
+ // binary-extension list, scanContentForSecrets, lineOf).
6
+ // changed: Finipe path allowlist + git/fs walker removed (runner owns file listing; allowlisting is the
7
+ // customer's .fini-proof.json `ignore`), and the pure scanner is exported. Part (b), log redaction,
8
+ // is NestJS-specific (backend/src logger helpers) and is NOT ported in v0.1.
9
+
10
+ // ── (a) secret-material patterns ────────────────────────────────────────
11
+
12
+ const AWS_ACCESS_KEY_RE = /\bAKIA[0-9A-Z]{16}\b/g
13
+ const PRIVATE_KEY_BLOCK_RE = /-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY-----/g
14
+ const JWT_ASSIGNED_RE = /([A-Za-z_$][\w$]*)\s*[:=]\s*['"`](eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})['"`]/g
15
+ const SLACK_TOKEN_RE = /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g
16
+ const STRIPE_KEY_RE = /\b(?:sk|rk)_live_[0-9A-Za-z]{10,}\b/g
17
+ const SENDGRID_KEY_RE = /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/g
18
+ // Any KEY=VALUE line in an .env* file — filtered below to those whose key
19
+ // has PASSWORD/SECRET/TOKEN as a whole UNDERSCORE-DELIMITED SEGMENT (never
20
+ // a plain substring: `_` is a \w character, so a regex `\bPASSWORD\b` does
21
+ // NOT see a boundary between `DB_` and `PASSWORD` and would silently match
22
+ // nothing — segment-splitting is used instead of `\b` for exactly this
23
+ // reason). `TOKENS` (plural) is therefore excluded automatically: it is
24
+ // never its own segment. A second pass drops keys that are self-evidently a
25
+ // numeric limit/toggle (`_CAP`, `_LIMIT`, `_MAX`, `_ENABLED`, …) despite
26
+ // containing one of those words as a segment, e.g.
27
+ // `COPILOT_MONTHLY_TOKEN_CAP_PER_TENANT`.
28
+ // NOTE: the whitespace around `=` is deliberately `[ \t]*`, never `\s*` —
29
+ // `\s` matches `\n`, and a greedy `\s*` immediately before a `(.*)` capture
30
+ // will happily cross the newline and swallow the ENTIRE NEXT LINE as this
31
+ // line's "value" on backtracking (caught by this gate's own self-test: an
32
+ // empty `KEY=` line was mis-captured as holding the next line's full text).
33
+ const ENV_ASSIGNMENT_RE = /^[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=(.*)$/gim
34
+ const ENV_CREDENTIAL_SEGMENTS = new Set(['PASSWORD', 'SECRET', 'TOKEN'])
35
+ const ENV_NON_CREDENTIAL_SEGMENTS = new Set(['CAP', 'LIMIT', 'MAX', 'COUNT', 'SIZE', 'TTL', 'EXPIRY', 'EXPIRES', 'DURATION', 'ENABLED', 'DISABLED'])
36
+
37
+ function envKeyLooksLikeCredential(key) {
38
+ const segments = key.toUpperCase().split('_')
39
+ if (!segments.some(s => ENV_CREDENTIAL_SEGMENTS.has(s))) return false
40
+ if (segments.some(s => ENV_NON_CREDENTIAL_SEGMENTS.has(s))) return false
41
+ return true
42
+ }
43
+
44
+ // Substrings that are self-evidently placeholder, not real secret material —
45
+ // deliberately broad, as a substring test anywhere in the value. A security
46
+ // gate's false positives get reviewed and allowlisted or fixed here; its
47
+ // false NEGATIVES ship a real secret, which is the worse failure direction.
48
+ const PLACEHOLDER_SUBSTRINGS = [
49
+ 'change-me', 'changeme', 'change_me', 'replace', 'your-', 'your_',
50
+ 'todo', 'placeholder', 'example', 'sample', 'dummy', 'fake', 'stub',
51
+ 'not-set', 'not_set', '<', '>', '...',
52
+ ]
53
+ const PLACEHOLDER_EXACT_RE = /^["']?(?:|none|null|undefined|true|false|dev|development|staging|local|test)["']?$/i
54
+ // Inline env-file comment (`KEY= # explanatory text`, or `KEY=# text` once
55
+ // the assignment regex has already eaten the leading whitespace) is not a
56
+ // value.
57
+ const ENV_INLINE_COMMENT_RE = /(?:^|\s)#.*$/
58
+ // A value that is one character repeated (`0000…0`, `xxxxxxxx`) end to end.
59
+ const REPEATED_CHAR_VALUE_RE = /^(.)\1{5,}$/
60
+ // Universally-known, publicly-documented default credentials for local
61
+ // dev-only services — not secrets by any definition (anyone can look them
62
+ // up in the tool's own docs); flagging them is pure noise.
63
+ const KNOWN_DEV_DEFAULT_VALUES = new Set(['minioadmin', 'postgres', 'guest'])
64
+
65
+ function isPlaceholderEnvValue(rawValue) {
66
+ const v = rawValue.replace(ENV_INLINE_COMMENT_RE, '').trim()
67
+ if (!v) return true
68
+ const unquoted = v.replace(/^["']|["']$/g, '')
69
+ if (PLACEHOLDER_EXACT_RE.test(v)) return true
70
+ if (KNOWN_DEV_DEFAULT_VALUES.has(unquoted.toLowerCase())) return true
71
+ if (REPEATED_CHAR_VALUE_RE.test(unquoted)) return true
72
+ const lower = v.toLowerCase()
73
+ return PLACEHOLDER_SUBSTRINGS.some(s => lower.includes(s))
74
+ }
75
+
76
+ // Allowlisted EXACTLY by path — fixtures/test vectors that legitimately embed
77
+ // fake-shaped secret material to exercise the code that handles real ones.
78
+ const BINARY_EXT_RE = /\.(png|jpg|jpeg|gif|webp|ico|pdf|woff2?|ttf|eot|zip|gz|tgz|jar|class|so|dylib|dll|exe|bin|wasm|mp4|mov|mp3|db|sqlite)$/i
79
+
80
+ /**
81
+ * Scan one file's CONTENT (already read) for secret material. Pure function —
82
+ * no fs/git access — so the self-test below can drive it directly on fixture
83
+ * strings without needing the fixture to be `git`-tracked.
84
+ */
85
+ function scanContentForSecrets(relPath, content) {
86
+ const findings = []
87
+ const isEnvFile = /(^|\/)\.env(\..+)?$/.test(relPath)
88
+
89
+ for (const re of [AWS_ACCESS_KEY_RE, PRIVATE_KEY_BLOCK_RE, SLACK_TOKEN_RE, STRIPE_KEY_RE, SENDGRID_KEY_RE]) {
90
+ re.lastIndex = 0
91
+ let m
92
+ while ((m = re.exec(content)) !== null) {
93
+ findings.push({ kind: re === AWS_ACCESS_KEY_RE ? 'aws-access-key' : re === PRIVATE_KEY_BLOCK_RE ? 'private-key-block' : re === SLACK_TOKEN_RE ? 'slack-token' : re === STRIPE_KEY_RE ? 'stripe-live-key' : 'sendgrid-key', line: lineOf(content, m.index), match: m[0].slice(0, 24) + (m[0].length > 24 ? '…' : '') })
94
+ }
95
+ }
96
+
97
+ {
98
+ JWT_ASSIGNED_RE.lastIndex = 0
99
+ let m
100
+ while ((m = JWT_ASSIGNED_RE.exec(content)) !== null) {
101
+ findings.push({ kind: 'jwt-assigned-to-config', line: lineOf(content, m.index), match: `${m[1]} = ${m[2].slice(0, 20)}…` })
102
+ }
103
+ }
104
+
105
+ if (isEnvFile) {
106
+ ENV_ASSIGNMENT_RE.lastIndex = 0
107
+ let m
108
+ while ((m = ENV_ASSIGNMENT_RE.exec(content)) !== null) {
109
+ const key = m[1]
110
+ if (!envKeyLooksLikeCredential(key)) continue
111
+ if (isPlaceholderEnvValue(m[2])) continue
112
+ findings.push({ kind: 'env-secret-assignment', line: lineOf(content, m.index), match: `${key}=<redacted-for-report>` })
113
+ }
114
+ }
115
+
116
+ return findings
117
+ }
118
+
119
+ function lineOf(src, idx) {
120
+ return src.slice(0, idx).split('\n').length
121
+ }
122
+
123
+ export { scanContentForSecrets, BINARY_EXT_RE, lineOf }