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,121 @@
1
+ // fini-proof engine: hollow-tests — tests that cannot fail.
2
+ // JS/TS: a thin adapter over the ported Finipe F4 detector (./vendor/vacuous-spec-core.mjs, provenance there).
3
+ // Python: NEW in fini-proof (Finipe has no Python test tree, so there was nothing to port); it applies the same
4
+ // two load-bearing rules (NO_ASSERTION, TAUTOLOGICAL) to pytest/unittest functions.
5
+ import { findVacuousSpecs, findTestBlocks, setRoot } from './vendor/vacuous-spec-core.mjs'
6
+
7
+ // NEW (fini-proof): the ported F4 tautology rule is written for jest's expect(); node:assert tautologies
8
+ // (assert(true), assert.ok(1), assert.equal(3, 3)) are added here for node:test / mocha+assert suites.
9
+ const LIT = String.raw`(?:true|false|null|undefined|-?\d+(?:\.\d+)?|'[^']*'|"[^"]*")`
10
+ const NODE_ASSERT_RE = /\bassert(?:\.(\w+))?\s*\(([^;\n]*)\)\s*;?\s*$/
11
+ const NODE_TAUT_RE = new RegExp(String.raw`^\s*(?:(?:ok|strict)?\s*\(?\s*(?:true|1|'[^']+'|"[^"]+")\s*(?:,.*)?|(?:equal|strictEqual|deepEqual|deepStrictEqual)\s*\(?\s*(${LIT})\s*,\s*\1\s*(?:,.*)?)$`)
12
+ function nodeAssertTautologies(source, already) {
13
+ const out = []
14
+ for (const b of findTestBlocks(source)) {
15
+ if (b.kind !== 'active' || already.has(b.line) || /\bexpect\s*\(/.test(b.bodyMasked)) continue
16
+ const calls = b.bodyText.split('\n').map((l) => NODE_ASSERT_RE.exec(l.trim())).filter(Boolean)
17
+ if (calls.length && calls.every((m) => NODE_TAUT_RE.test(`${m[1] ? m[1] + '(' : ''}${m[2]}`))) {
18
+ out.push({ line: b.line, rule: 'TAUTOLOGICAL', severity: 'high', message: 'every assertion in this test is a constant (assert(true) / assert.ok(true) / assert.equal(1, 1)) — it passes whatever the code does. FIX: assert on the real output of the unit under test.' })
19
+ }
20
+ }
21
+ return out
22
+ }
23
+
24
+ const JS_TEST_RE = /(^|\/)(__tests__\/.*\.(js|jsx|ts|tsx|mjs|cjs)$|[^/]+\.(spec|test)\.(js|jsx|ts|tsx|mjs|cjs)$)/
25
+ const PY_TEST_RE = /(^|\/)(test_[^/]*\.py|[^/]*_test\.py)$/
26
+
27
+ const SEVERITY = {
28
+ NO_ASSERTION: 'high', TAUTOLOGICAL: 'high', SWALLOWED_CATCH: 'high', PROMISE_NOT_AWAITED: 'high',
29
+ DONE_EARLY: 'high', EARLY_RETURN: 'medium', MOCK_ONLY_ASSERTION: 'medium', TYPE_ONLY_ASSERTION: 'medium',
30
+ KEY_PRESENCE_ONLY: 'medium',
31
+ }
32
+ // Skips are owned by the skip-only-ratchet engine (one finding per defect, never two).
33
+ const SKIP_RULES = new Set(['UNLINKED_SKIP', 'CONDITIONAL_SKIP'])
34
+
35
+ // ── Python (new) ───────────────────────────────────────────────────────────
36
+ const PY_ASSERT_RE = /(^|[^\w.])assert\b|\bself\.assert\w*\s*\(|\bself\.fail\s*\(|\bpytest\.(raises|warns|fail|approx)\b|\.assert_\w+\s*\(|\braise\b/
37
+ const PY_TAUTOLOGY_RE = /^\s*assert\s+(True|1|not\s+False|(["'][^"']*["']))\s*(,.*)?$|^\s*self\.assertTrue\(\s*True\s*\)\s*$|^\s*self\.assertEqual\(\s*(\d+|True|["'][^"']*["'])\s*,\s*\5\s*\)\s*$/
38
+
39
+ export function scanPython(relPath, source) {
40
+ const lines = source.split('\n')
41
+ const out = []
42
+ for (let i = 0; i < lines.length; i++) {
43
+ const m = /^(\s*)(?:async\s+)?def\s+(test\w*)\s*\(/.exec(lines[i])
44
+ if (!m) continue
45
+ const indent = m[1].length
46
+ const body = []
47
+ let j = i + 1
48
+ // skip continuation of a multi-line signature
49
+ while (j < lines.length && !/\)\s*(->.*)?:\s*(#.*)?$/.test(lines[j - 1]) && j - i < 20) j++
50
+ for (; j < lines.length; j++) {
51
+ const l = lines[j]
52
+ if (l.trim() === '' || /^\s*#/.test(l)) continue
53
+ if (l.search(/\S/) <= indent) break
54
+ body.push(l)
55
+ }
56
+ const code = body.filter((l) => !/^\s*("""|''')/.test(l))
57
+ const asserting = code.filter((l) => PY_ASSERT_RE.test(l.replace(/(["'])(?:(?!\1).)*\1/g, '""')))
58
+ if (asserting.length === 0) {
59
+ out.push({ line: i + 1, rule: 'NO_ASSERTION', message: `test \`${m[2]}\` makes no assertion (no assert / self.assert* / pytest.raises / mock.assert_*) — it can never fail. FIX: assert the behaviour it claims to cover.` })
60
+ } else if (asserting.every((l) => PY_TAUTOLOGY_RE.test(l))) {
61
+ out.push({ line: i + 1, rule: 'TAUTOLOGICAL', message: `every assertion in \`${m[2]}\` is a constant (assert True / assertEqual(1, 1)) — it passes whatever the code does. FIX: assert on the real output of the unit under test.` })
62
+ }
63
+ }
64
+ return out
65
+ }
66
+
67
+ export default {
68
+ id: 'hollow-tests',
69
+ version: '0.1.0',
70
+ title: 'Hollow tests (tests that cannot fail)',
71
+ provenance: 'finipe scripts/ci/check-vacuous-spec.mjs @ 4ac53b367 (JS/TS); Python rules new',
72
+ appliesTo: (rel) => JS_TEST_RE.test(rel) || PY_TEST_RE.test(rel),
73
+ scanFile(rel, content, ctx) {
74
+ if (PY_TEST_RE.test(rel)) return scanPython(rel, content).map((f) => ({ ...f, severity: SEVERITY[f.rule] }))
75
+ setRoot(ctx.root)
76
+ const found = findVacuousSpecs(rel, content)
77
+ .filter((v) => !SKIP_RULES.has(v.rule))
78
+ .map((v) => ({ line: v.line, rule: v.rule, severity: SEVERITY[v.rule] || 'medium', message: v.message }))
79
+ return [...found, ...nodeAssertTautologies(content, new Set(found.map((f) => f.line)))].sort((a, b) => a.line - b.line)
80
+ },
81
+ rules: {
82
+ NO_ASSERTION: { severity: 'high', summary: 'the test calls code but asserts nothing — it can never fail', fix: 'assert the behaviour the test name claims' },
83
+ TAUTOLOGICAL: { severity: 'high', summary: 'every assertion is a constant (expect(true).toBe(true), assert True) — passes whatever the code does', fix: 'assert on the real output of the unit under test' },
84
+ SWALLOWED_CATCH: { severity: 'high', summary: 'the code under test runs inside try/catch whose catch does not fail the test — errors are swallowed', fix: 'let it throw, or assert on the error (expect(...).rejects / pytest.raises)' },
85
+ PROMISE_NOT_AWAITED: { severity: 'high', summary: 'an async assertion is not awaited/returned — the test finishes before it runs', fix: 'await or return the promise' },
86
+ DONE_EARLY: { severity: 'high', summary: 'done() is called before the async assertion runs', fix: 'call done() after the assertion, or use async/await' },
87
+ EARLY_RETURN: { severity: 'medium', summary: 'the test returns before its assertions', fix: 'remove the early return' },
88
+ MOCK_ONLY_ASSERTION: { severity: 'medium', summary: 'the only assertion is that a mock was called — the real result is never checked', fix: 'also assert on the returned value or state' },
89
+ TYPE_ONLY_ASSERTION: { severity: 'medium', summary: 'the only assertion is a type/defined check', fix: 'assert the actual value' },
90
+ KEY_PRESENCE_ONLY: { severity: 'medium', summary: 'the only assertion is that keys exist, not what they hold', fix: 'assert the values' },
91
+ },
92
+ negativeControls: [
93
+ { id: 'hollow-tests/NC-1', file: 'nc/sum.test.js', expect: 'NO_ASSERTION', content: "const sum = (a, b) => a + b\nit('adds', () => {\n sum(1, 2)\n})\n" },
94
+ { id: 'hollow-tests/NC-2', file: 'nc/sum.spec.ts', expect: 'TAUTOLOGICAL', content: "it('adds', () => {\n expect(true).toBe(true)\n})\n" },
95
+ { id: 'hollow-tests/NC-3', file: 'nc/pay.test.js', expect: 'SWALLOWED_CATCH', content: "it('pays', async () => {\n try {\n await pay(10)\n } catch (e) {\n console.log(e)\n }\n expect(pay).toBeDefined()\n})\n" },
96
+ { id: 'hollow-tests/NC-6', file: 'nc/gst.test.mjs', expect: 'TAUTOLOGICAL', content: "import assert from 'node:assert/strict'\ntest('gst', () => {\n const tax = gst(1000)\n assert.ok(true)\n})\n" },
97
+ { id: 'hollow-tests/NC-4', file: 'nc/test_sum.py', expect: 'NO_ASSERTION', content: 'def test_sum():\n total = sum([1, 2])\n' },
98
+ { id: 'hollow-tests/NC-5', file: 'nc/test_ok.py', expect: 'TAUTOLOGICAL', content: 'def test_ok():\n run()\n assert True\n' },
99
+ { id: 'hollow-tests/NC-7', file: 'nc/load.test.js', expect: 'PROMISE_NOT_AWAITED', content: "it('loads', () => {\n load().then((r) => {\n expect(r.ok).toBe(true)\n })\n})\n" },
100
+ { id: 'hollow-tests/NC-8', file: 'nc/fetch.test.js', expect: 'DONE_EARLY', content: "it('fetches', (done) => {\n load().then((r) => {\n expect(r.ok).toBe(true)\n })\n done()\n})\n" },
101
+ { id: 'hollow-tests/NC-9', file: 'nc/refund.test.js', expect: 'EARLY_RETURN', content: "it('refunds', () => {\n if (!process.env.GATEWAY) return\n expect(refund(10)).toBe(10)\n})\n" },
102
+ { id: 'hollow-tests/NC-10', file: 'nc/charge.test.js', expect: 'MOCK_ONLY_ASSERTION', content: "it('charges', () => {\n const charge = jest.fn().mockReturnValue(10)\n expect(charge(5)).toBe(10)\n})\n" },
103
+ { id: 'hollow-tests/NC-11', file: 'nc/status.test.ts', expect: 'TYPE_ONLY_ASSERTION', content: "it('reports status', async () => {\n const r: { status?: 'ok' } = await fetchStatus()\n expect(r).toBeDefined()\n})\n" },
104
+ { id: 'hollow-tests/NC-12', file: 'nc/pay.test.js', expect: 'KEY_PRESENCE_ONLY', content: "it('pays', () => {\n const res = pay(10)\n expect(res).toHaveProperty('status')\n})\n" },
105
+ ],
106
+ // Positive controls: each `rule` names the rule it is the NEAREST CLEAN LOOK-ALIKE for, so a detector that grows
107
+ // too lax for that rule's neighbour goes red here (SSOT PART 7 corpus). A control without `rule` is general.
108
+ positiveControls: [
109
+ { id: 'hollow-tests/PC-1', rule: 'NO_ASSERTION', file: 'pc/sum.test.js', content: "it('adds', () => {\n expect(sum(1, 2)).toBe(3)\n})\n" },
110
+ { id: 'hollow-tests/PC-2', rule: 'TAUTOLOGICAL', file: 'pc/sum.test.mjs', content: "import assert from 'node:assert/strict'\ntest('adds', () => {\n assert.equal(sum(1, 2), 3)\n})\n" },
111
+ { id: 'hollow-tests/PC-3', file: 'pc/test_sum.py', content: 'def test_sum():\n assert sum([1, 2]) == 3\n\ndef test_raises():\n with pytest.raises(ValueError):\n parse("x")\n' },
112
+ { id: 'hollow-tests/PC-4', rule: 'SWALLOWED_CATCH', file: 'pc/pay.test.js', content: "it('rejects a negative amount', async () => {\n try {\n await pay(-1)\n throw new Error('expected pay(-1) to reject')\n } catch (e) {\n expect(e.message).toMatch(/amount/)\n }\n})\n" },
113
+ { id: 'hollow-tests/PC-5', rule: 'PROMISE_NOT_AWAITED', file: 'pc/load.test.js', content: "it('loads', () => {\n return load().then((r) => {\n expect(r.ok).toBe(true)\n })\n})\n" },
114
+ { id: 'hollow-tests/PC-6', rule: 'DONE_EARLY', file: 'pc/fetch.test.js', content: "it('fetches', (done) => {\n load().then((r) => {\n expect(r.ok).toBe(true)\n done()\n })\n})\n" },
115
+ { id: 'hollow-tests/PC-7', rule: 'EARLY_RETURN', file: 'pc/refund.test.js', content: "it('refunds', () => {\n const pick = (x) => { if (!x) return 0; return x }\n expect(refund(pick(10))).toBe(10)\n})\n" },
116
+ { id: 'hollow-tests/PC-8', rule: 'MOCK_ONLY_ASSERTION', file: 'pc/charge.test.js', content: "it('charges through the gateway', () => {\n const gateway = jest.fn().mockReturnValue(10)\n expect(checkout(gateway, 5)).toBe(10)\n})\n" },
117
+ { id: 'hollow-tests/PC-9', rule: 'TYPE_ONLY_ASSERTION', file: 'pc/status.test.ts', content: "it('reports status', async () => {\n const r: { status?: 'ok' } = await fetchStatus()\n expect(r.status).toBe('ok')\n})\n" },
118
+ { id: 'hollow-tests/PC-10', rule: 'KEY_PRESENCE_ONLY', file: 'pc/pay.test.js', content: "it('pays', () => {\n const res = pay(10)\n expect(res).toHaveProperty('status', 'paid')\n})\n" },
119
+ ],
120
+ }
121
+
@@ -0,0 +1,316 @@
1
+ // fini-proof engine: migrations — database migrations that lose data, lock or fail on a live table, or cannot be
2
+ // rolled back. SQL (plain, Flyway, golang-migrate, goose, dbmate), Prisma, TypeORM, Knex, Alembic (and Django's
3
+ // destructive operations).
4
+ //
5
+ // PROVENANCE. Ported from finipe scripts/ci/lib/migration-invariants.cjs @ 4ac53b367 (last changed 18ce3086b):
6
+ // - expandStatic() (VERBATIM, ./vendor/migration-invariants-core.mjs) expands `for (const t of [...])` loops so the
7
+ // up()/down() table sets are read per table, exactly as Finipe's preflight stage 3 does;
8
+ // - the "no down() — the migration is not reversible" rule and the "down() must drop what up() created, in reverse
9
+ // order" rule (downReversal(), adapted there) are Finipe's.
10
+ // NEW in fini-proof (Finipe's gate is RLS-shaped for one Postgres schema; customer repos need the destructive-change
11
+ // class): DROP TABLE / DROP COLUMN, column type changes, ADD COLUMN … NOT NULL without DEFAULT, SET NOT NULL, the
12
+ // per-framework readers (SQL markers, Knex, TypeORM API, Alembic op.*, Django), and the acknowledgement comment
13
+ // `fini-proof: allow <RULE> <reason>` for a destructive step a human has decided on (expand/contract).
14
+ // v0.3 (false-positive pass on 3 public SaaS repos): (a) a DROP of a table/column that the same up step created
15
+ // earlier is a temp object, not data loss; (b) the APPLIED-MIGRATIONS BASELINE — `fini-proof baseline` records the
16
+ // migrations already applied (path → sha256) in .fini-proof/baseline.json; an applied, unchanged migration cannot be
17
+ // fixed any more, so it is not re-judged (counted in the evidence as appliedBaselined). Editing an applied migration
18
+ // changes its hash and puts it back in scope; changing the baseline is itself a gated change (codex agent-gate).
19
+ import { createHash } from 'node:crypto'
20
+ import { balanced, expandStatic, downReversal } from './vendor/migration-invariants-core.mjs'
21
+
22
+ export const MIGRATION_PATH_RE = /(^|\/)(migrations?|migrate|db\/migrate|alembic\/versions)\//i
23
+ const EXT_RE = /\.(sql|ts|js|mjs|cjs|py)$/
24
+ const TEST_RE = /\.(spec|test)\.[a-z]+$|(^|\/)(__tests__|__mocks__)\//
25
+ const DOWN_SQL_RE = /(\.down\.sql$|(^|\/)down\.sql$|(^|\/)U\d[\w.]*__[^/]*\.sql$)/i
26
+ const FLYWAY_RE = /(^|\/)[VR]\d[\w.]*__[^/]*\.sql$/i
27
+ const PRISMA_RE = /(^|\/)prisma\/migrations\//i
28
+ const INLINE_UP_RE = /^\s*--\s*(?:\+goose\s+up|\+migrate\s+up|migrate:up)\b/im
29
+ const INLINE_DOWN_RE = /^\s*--\s*(?:\+goose\s+down|\+migrate\s+down|migrate:down)\b/im
30
+
31
+ const lineOf = (src, idx) => src.slice(0, idx).split('\n').length
32
+
33
+ /** Blank out comments (keep strings, keep offsets). lang: 'sql' | 'js' | 'py'. */
34
+ function stripComments(src, lang) {
35
+ let out = ''
36
+ let i = 0
37
+ const n = src.length
38
+ let quote = null
39
+ while (i < n) {
40
+ const c = src[i]
41
+ if (quote) {
42
+ out += c
43
+ if (c === '\\' && lang !== 'sql') { out += src[i + 1] || ''; i += 2; continue }
44
+ if (c === quote) quote = null
45
+ i++; continue
46
+ }
47
+ if (c === "'" || c === '"' || (c === '`' && lang === 'js')) { quote = c; out += c; i++; continue }
48
+ const two = src.slice(i, i + 2)
49
+ if ((lang === 'sql' && two === '--') || (lang === 'js' && two === '//') || (lang === 'py' && c === '#')) {
50
+ while (i < n && src[i] !== '\n') { out += ' '; i++ }
51
+ continue
52
+ }
53
+ if ((lang === 'sql' || lang === 'js') && two === '/*') {
54
+ while (i < n && src.slice(i, i + 2) !== '*/') { out += src[i] === '\n' ? '\n' : ' '; i++ }
55
+ out += ' '; i += 2; continue
56
+ }
57
+ out += c; i++
58
+ }
59
+ return out
60
+ }
61
+
62
+ const RULE_TEXT = {
63
+ DROP_TABLE: 'drops a table — every row in it is deleted, and the running application version may still read it. FIX: expand/contract — stop using the table in one release, drop it in a later one after a backup; acknowledge with `fini-proof: allow DROP_TABLE <ticket>`.',
64
+ DROP_COLUMN: 'drops a column — its data is gone and the app version still running during the deploy may still read or write it. FIX: stop reading/writing it first, drop it in a later release; acknowledge with `fini-proof: allow DROP_COLUMN <ticket>`.',
65
+ ALTER_COLUMN_TYPE: 'changes a column type — this can rewrite and lock the whole table, and can truncate or reject existing values. FIX: add a new column, back-fill in batches, switch reads, then drop the old one.',
66
+ ADD_NOT_NULL_NO_DEFAULT: 'adds a NOT NULL column without a DEFAULT — on a table that already has rows the migration fails (or forces a table rewrite). FIX: add it nullable (or with a DEFAULT), back-fill, then SET NOT NULL.',
67
+ SET_NOT_NULL: 'sets NOT NULL on an existing column — it fails if any row holds NULL and scans the table under lock. FIX: back-fill first; on Postgres add a NOT VALID CHECK constraint and VALIDATE it, then SET NOT NULL.',
68
+ MISSING_DOWN: 'has no down / rollback step — if this deploy goes wrong there is no scripted way back. FIX: write the down migration (or the .down.sql / downgrade()).',
69
+ DOWN_INCOMPLETE: 'rollback does not drop a table that the up step creates — rolling back leaves the schema half-migrated.',
70
+ DOWN_ORDER: 'rollback drops tables in a different order than the reverse of up — foreign keys unwind in reverse or not at all.',
71
+ }
72
+ const SEVERITY = { DROP_TABLE: 'high', DROP_COLUMN: 'high', ALTER_COLUMN_TYPE: 'high', ADD_NOT_NULL_NO_DEFAULT: 'high', SET_NOT_NULL: 'medium', MISSING_DOWN: 'medium', DOWN_INCOMPLETE: 'medium', DOWN_ORDER: 'low' }
73
+
74
+ function mk(src, idx, rule, detail) {
75
+ return { line: lineOf(src, idx), rule, severity: SEVERITY[rule], message: `${detail ? detail + ' — ' : 'this migration '}${RULE_TEXT[rule]}` }
76
+ }
77
+
78
+ /** End of an ADD COLUMN clause: `,` / `)` at depth 0, `;`, or a closing JS template/quote run-out. */
79
+ function clauseEnd(text, from) {
80
+ let depth = 0
81
+ for (let i = from; i < text.length; i++) {
82
+ const c = text[i]
83
+ if (c === '(') depth++
84
+ else if (c === ')') { if (depth === 0) return i; depth-- }
85
+ else if ((c === ',' && depth === 0) || c === ';' || c === '`') return i
86
+ }
87
+ return text.length
88
+ }
89
+
90
+ /** SQL rules over (comment-stripped) text: the up part of a .sql file, or SQL inside JS/Python strings. */
91
+ function sqlRules(text, base = 0, full = text) {
92
+ const out = []
93
+ const at = (i, rule, d) => out.push(mk(full, base + i, rule, d))
94
+ const bare = (x) => x.replace(/["`]/g, '').split('.').pop().toLowerCase()
95
+ // created earlier in this same step → a temp object; dropping it loses no existing data
96
+ const madeBefore = (re, name, i) => [...text.slice(0, i).matchAll(re)].some((c) => bare(c[1]) === bare(name))
97
+ const CREATED_TABLE_RE = /CREATE\s+(?:TEMP(?:ORARY)?\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w."`]+)/gi
98
+ const ADDED_COLUMN_RE = /\bADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(?!CONSTRAINT\b|PRIMARY\b|FOREIGN\b|UNIQUE\b|INDEX\b|KEY\b|CHECK\b)([\w"`]+)\s/gi
99
+ for (const m of text.matchAll(/\bDROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."`]+)/gi)) if (!madeBefore(CREATED_TABLE_RE, m[1], m.index)) at(m.index, 'DROP_TABLE', `DROP TABLE ${m[1].replace(/["`]/g, '')}`)
100
+ for (const m of text.matchAll(/\bDROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?([\w"`]+)/gi)) if (!madeBefore(ADDED_COLUMN_RE, m[1], m.index)) at(m.index, 'DROP_COLUMN', `DROP COLUMN ${m[1].replace(/["`]/g, '')}`)
101
+ for (const m of text.matchAll(/\bALTER\s+COLUMN\s+([\w"`]+)\s+(?:SET\s+DATA\s+)?TYPE\b|\b(?:MODIFY|CHANGE)\s+COLUMN\s+([\w"`]+)/gi)) at(m.index, 'ALTER_COLUMN_TYPE', `column ${(m[1] || m[2]).replace(/["`]/g, '')} changes type`)
102
+ for (const m of text.matchAll(/\bALTER\s+COLUMN\s+([\w"`]+)\s+SET\s+NOT\s+NULL\b/gi)) at(m.index, 'SET_NOT_NULL', `column ${m[1].replace(/["`]/g, '')} SET NOT NULL`)
103
+ for (const m of text.matchAll(/\bADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(?!CONSTRAINT\b|PRIMARY\b|FOREIGN\b|UNIQUE\b|INDEX\b|KEY\b|CHECK\b)([\w"`]+)\s/gi)) {
104
+ if (!/\bALTER\s+TABLE\b/i.test(text.slice(Math.max(0, m.index - 400), m.index))) continue
105
+ const clause = text.slice(m.index, clauseEnd(text, m.index + m[0].length))
106
+ if (/\bNOT\s+NULL\b/i.test(clause) && !/\bDEFAULT\b/i.test(clause) && !/\b(SERIAL|BIGSERIAL|SMALLSERIAL|GENERATED|IDENTITY|AUTO_INCREMENT)\b/i.test(clause)) {
107
+ at(m.index, 'ADD_NOT_NULL_NO_DEFAULT', `ADD COLUMN ${m[1].replace(/["`]/g, '')} NOT NULL`)
108
+ }
109
+ }
110
+ return out
111
+ }
112
+ const sqlCreates = (t) => [...t.matchAll(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:[\w"`]+\.)?["`]?(\w+)["`]?\s*\(/gi)].map((m) => m[1].toLowerCase())
113
+ const sqlDrops = (t) => [...t.matchAll(/DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:[\w"`]+\.)?["`]?(\w+)["`]?/gi)].map((m) => m[1].toLowerCase())
114
+
115
+ // ── SQL files ───────────────────────────────────────────────────────────────
116
+ function scanSql(rel, content, ctx) {
117
+ if (DOWN_SQL_RE.test(rel)) return [] // a rollback file is expected to drop things
118
+ const text = stripComments(content, 'sql')
119
+ const downAt = content.search(INLINE_DOWN_RE)
120
+ const up = downAt >= 0 ? text.slice(0, downAt) : text
121
+ const out = sqlRules(up, 0, content)
122
+ if (PRISMA_RE.test(rel) || FLYWAY_RE.test(rel)) return out // these tools have no down migration by design
123
+ const down = downAt >= 0 ? text.slice(downAt) : null
124
+ if (/\.up\.sql$/i.test(rel) || /(^|\/)up\.sql$/i.test(rel)) {
125
+ const sib = rel.replace(/\.up\.sql$/i, '.down.sql').replace(/(^|\/)up\.sql$/i, '$1down.sql')
126
+ if (!ctx.exists?.(sib)) out.push(mk(content, 0, 'MISSING_DOWN', `no ${sib.split('/').pop()} next to it`))
127
+ else out.push(...reversal(content, 0, sqlCreates(up), sqlDrops(ctx.read?.(sib) || '')))
128
+ } else if (INLINE_UP_RE.test(content)) {
129
+ if (!down || !down.replace(INLINE_DOWN_RE, '').trim()) out.push(mk(content, content.search(INLINE_UP_RE), 'MISSING_DOWN', 'the Up section has no (non-empty) Down section'))
130
+ else out.push(...reversal(content, downAt, sqlCreates(up), sqlDrops(down)))
131
+ }
132
+ return out
133
+ }
134
+
135
+ function reversal(src, idx, creates, drops) {
136
+ return downReversal(creates, drops).map((p) => p.rule === 'DOWN_INCOMPLETE'
137
+ ? mk(src, idx, 'DOWN_INCOMPLETE', `down never drops ${p.tables.join(', ')}`)
138
+ : mk(src, idx, 'DOWN_ORDER', `down drops ${p.actual.join(' → ')}; reverse of up is ${p.expected.join(' → ')}`))
139
+ }
140
+
141
+ // ── JS / TS (TypeORM, Knex, generic up/down exports) ─────────────────────────
142
+ const UP_DEF_RE = /(?:async\s+up\s*\(|\bup\s*\([^)]*\)\s*(?::\s*[^{]+)?\{|exports\.up\s*=|export\s+(?:async\s+)?function\s+up\b|export\s+const\s+up\b|\bup\s*:\s*(?:async\s*)?(?:function|\())/
143
+ const DOWN_DEF_RE = /(?:async\s+down\s*\(|\bdown\s*\([^)]*\)\s*(?::\s*[^{]+)?\{|exports\.down\s*=|export\s+(?:async\s+)?function\s+down\b|export\s+const\s+down\b|\bdown\s*:\s*(?:async\s*)?(?:function|\())/
144
+
145
+ function bodyAfter(text, idx) {
146
+ const open = text.indexOf('{', idx)
147
+ if (open < 0) return null
148
+ const close = balanced(text, open, '{', '}')
149
+ return close < 0 ? null : { open, close, body: text.slice(open + 1, close) }
150
+ }
151
+
152
+ function scanJs(rel, content) {
153
+ const text = stripComments(content, 'js')
154
+ const upAt = text.search(UP_DEF_RE)
155
+ if (upAt < 0) return [] // not a migration module (helpers, index files)
156
+ const downAt = text.search(DOWN_DEF_RE)
157
+ const upEnd = downAt > upAt ? downAt : text.length
158
+ const up = text.slice(upAt, upEnd)
159
+ const out = sqlRules(up, upAt, content)
160
+ const at = (i, rule, d) => out.push(mk(content, upAt + i, rule, d))
161
+ for (const m of up.matchAll(/\.\s*(dropTable|dropTableIfExists)\s*\(\s*['"`]?([\w.]*)/g)) at(m.index, 'DROP_TABLE', `${m[1]}(${m[2] || '…'})`)
162
+ for (const m of up.matchAll(/\.\s*dropColumns?\s*\(\s*([^)]{0,80})/g)) at(m.index, 'DROP_COLUMN', `dropColumn(${m[1].trim().slice(0, 40)})`)
163
+ for (const m of up.matchAll(/\.\s*changeColumns?\s*\(/g)) at(m.index, 'ALTER_COLUMN_TYPE', 'queryRunner.changeColumn(…)')
164
+ // TypeORM addColumn(table, new TableColumn({...})): TableColumn is NOT NULL unless isNullable: true.
165
+ for (const m of up.matchAll(/\.\s*addColumns?\s*\(/g)) {
166
+ const open = m.index + m[0].length - 1
167
+ const close = balanced(up, open, '(', ')')
168
+ const args = up.slice(open, close < 0 ? up.length : close)
169
+ if (/TableColumn/.test(args) && !/isNullable\s*:\s*true/.test(args) && !/\bdefault\s*:/.test(args) && !/isGenerated\s*:\s*true/.test(args)) at(m.index, 'ADD_NOT_NULL_NO_DEFAULT', 'addColumn(new TableColumn) without isNullable: true or default')
170
+ }
171
+ // Knex alterTable / table callbacks: a column chain with .notNullable() and no .defaultTo(); `.alter()` = type change.
172
+ for (const m of up.matchAll(/\.\s*(alterTable|table)\s*\(/g)) {
173
+ const open = m.index + m[0].length - 1
174
+ const close = balanced(up, open, '(', ')')
175
+ const args = up.slice(open, close < 0 ? up.length : close)
176
+ // one statement = a line plus the following lines that continue its chain (`.notNullable()` on its own line)
177
+ let off = 0
178
+ const stmts = []
179
+ for (const line of args.split('\n')) {
180
+ if (/^\s*\./.test(line) && stmts.length) stmts[stmts.length - 1].text += line
181
+ else stmts.push({ at: off, text: line })
182
+ off += line.length + 1
183
+ }
184
+ for (const st of stmts) {
185
+ if (/\.\s*alter\s*\(\s*\)/.test(st.text)) at(open + st.at, 'ALTER_COLUMN_TYPE', 'knex column .alter()')
186
+ else if (/\.\s*notNullable\s*\(\s*\)/.test(st.text) && !/\.\s*defaultTo\s*\(/.test(st.text) && !/\.\s*(increments|bigIncrements)\s*\(/.test(st.text)) at(open + st.at, 'ADD_NOT_NULL_NO_DEFAULT', 'knex .notNullable() without .defaultTo() on an existing table')
187
+ }
188
+ }
189
+ // Reversibility (Finipe rule): no down → not reversible; empty down → same; down must drop what up created.
190
+ const downBody = downAt >= 0 ? bodyAfter(text, downAt) : null
191
+ if (downAt < 0) out.push(mk(content, upAt, 'MISSING_DOWN', 'no down() / exports.down'))
192
+ else if (downBody && !downBody.body.replace(/\breturn\b|Promise\.resolve\(\s*\)|[;\s]/g, '')) out.push(mk(content, downAt, 'MISSING_DOWN', 'down() is empty'))
193
+ else if (downBody) {
194
+ const upX = expandStatic(up).text
195
+ const downX = expandStatic(downBody.body).text
196
+ const creates = [...sqlCreates(upX), ...[...upX.matchAll(/\.\s*createTable\s*\(\s*(?:['"`](\w+)|new\s+Table\s*\(\s*\{[^}]*?name\s*:\s*['"`](\w+))/g)].map((x) => (x[1] || x[2]).toLowerCase())]
197
+ const drops = [...sqlDrops(downX), ...[...downX.matchAll(/\.\s*dropTable(?:IfExists)?\s*\(\s*['"`](\w+)/g)].map((x) => x[1].toLowerCase())]
198
+ out.push(...reversal(content, downAt, [...new Set(creates)], drops))
199
+ }
200
+ return out
201
+ }
202
+
203
+ // ── Python (Alembic, Django) ──────────────────────────────────────────────
204
+ function pyFunc(text, name) {
205
+ const m = new RegExp(`^def\\s+${name}\\s*\\([^)]*\\)[^:]*:`, 'm').exec(text)
206
+ if (!m) return null
207
+ const rest = text.slice(m.index + m[0].length)
208
+ const next = rest.search(/^\S/m)
209
+ const body = next < 0 ? rest : rest.slice(0, next)
210
+ return { at: m.index, body, text: text.slice(m.index, m.index + m[0].length + body.length) }
211
+ }
212
+
213
+ function scanPy(rel, content) {
214
+ const text = stripComments(content, 'py')
215
+ const out = []
216
+ const up = pyFunc(text, 'upgrade')
217
+ if (up) {
218
+ const base = up.at
219
+ const upText = up.text
220
+ out.push(...sqlRules(upText, base, content))
221
+ const at = (i, rule, d) => out.push(mk(content, base + i, rule, d))
222
+ for (const m of upText.matchAll(/\bop\.drop_table\s*\(\s*['"](\w+)/g)) at(m.index, 'DROP_TABLE', `op.drop_table('${m[1]}')`)
223
+ for (const m of upText.matchAll(/\bop\.drop_column\s*\(\s*['"](\w+)['"]\s*,\s*['"](\w+)/g)) at(m.index, 'DROP_COLUMN', `op.drop_column('${m[1]}', '${m[2]}')`)
224
+ for (const m of upText.matchAll(/\bop\.alter_column\s*\(/g)) {
225
+ const close = balanced(upText, m.index + m[0].length - 1, '(', ')')
226
+ const args = upText.slice(m.index, close < 0 ? upText.length : close)
227
+ if (/\btype_\s*=/.test(args)) at(m.index, 'ALTER_COLUMN_TYPE', 'op.alter_column(type_=…)')
228
+ if (/\bnullable\s*=\s*False\b/.test(args) && !/server_default\s*=/.test(args)) at(m.index, 'SET_NOT_NULL', 'op.alter_column(nullable=False)')
229
+ }
230
+ for (const m of upText.matchAll(/\bop\.add_column\s*\(/g)) {
231
+ const close = balanced(upText, m.index + m[0].length - 1, '(', ')')
232
+ const args = upText.slice(m.index, close < 0 ? upText.length : close)
233
+ if (/\bnullable\s*=\s*False\b/.test(args) && !/server_default\s*=/.test(args)) at(m.index, 'ADD_NOT_NULL_NO_DEFAULT', 'op.add_column(nullable=False) without server_default')
234
+ }
235
+ const down = pyFunc(text, 'downgrade')
236
+ if (!down) out.push(mk(content, base, 'MISSING_DOWN', 'no downgrade()'))
237
+ else if (!down.body.replace(/("""|''')[\s\S]*?\1/g, '').replace(/\bpass\b/g, '').trim()) out.push(mk(content, down.at, 'MISSING_DOWN', 'downgrade() does nothing'))
238
+ else {
239
+ const creates = [...upText.matchAll(/\bop\.create_table\s*\(\s*['"](\w+)/g)].map((m) => m[1].toLowerCase())
240
+ const drops = [...down.body.matchAll(/\bop\.drop_table\s*\(\s*['"](\w+)/g)].map((m) => m[1].toLowerCase())
241
+ out.push(...reversal(content, down.at, creates, drops))
242
+ }
243
+ return out
244
+ }
245
+ if (/\bmigrations\.Migration\b/.test(text)) {
246
+ const at = (i, rule, d) => out.push(mk(content, i, rule, d))
247
+ for (const m of text.matchAll(/\bmigrations\.DeleteModel\s*\(\s*(?:name\s*=\s*)?['"](\w+)/g)) at(m.index, 'DROP_TABLE', `DeleteModel('${m[1]}')`)
248
+ for (const m of text.matchAll(/\bmigrations\.RemoveField\s*\(/g)) at(m.index, 'DROP_COLUMN', 'RemoveField(…)')
249
+ out.push(...sqlRules(text, 0, content))
250
+ }
251
+ return out
252
+ }
253
+
254
+ /** `fini-proof: allow <RULE> <reason>` on the line or the 2 lines above acknowledges one destructive step. */
255
+ function acknowledged(content, f) {
256
+ const lines = content.split('\n')
257
+ const win = lines.slice(Math.max(0, f.line - 3), f.line).join('\n')
258
+ const m = new RegExp(`fini-proof:\\s*allow\\s+${f.rule}\\b\\s*[-—:]*\\s*(\\S.{2,})`).exec(win)
259
+ return Boolean(m)
260
+ }
261
+
262
+ export default {
263
+ id: 'migrations',
264
+ version: '0.3.0',
265
+ title: 'Dangerous database migrations',
266
+ provenance: 'finipe scripts/ci/lib/migration-invariants.cjs expandStatic + down() rules @ 4ac53b367 (last changed 18ce3086b); destructive-change rules new',
267
+ optionalScope: true,
268
+ notApplicableReason: 'no migration file in this repository (paths under migrations/, migrate/, db/migrate/, alembic/versions/)',
269
+ appliesTo: (rel) => MIGRATION_PATH_RE.test(rel) && EXT_RE.test(rel) && !TEST_RE.test(rel) && !/\.d\.ts$/.test(rel),
270
+ scanFile(rel, content, ctx = {}) {
271
+ const applied = ctx.baseline?.migrations?.applied?.[rel] === createHash('sha256').update(content).digest('hex')
272
+ const found = rel.endsWith('.sql') ? scanSql(rel, content, ctx) : rel.endsWith('.py') ? scanPy(rel, content) : scanJs(rel, content)
273
+ const seen = new Set()
274
+ return found
275
+ .filter((f) => { const k = `${f.line}:${f.rule}`; if (seen.has(k)) return false; seen.add(k); return true })
276
+ .filter((f) => !acknowledged(content, f))
277
+ .map((f) => (applied ? { ...f, applied: true } : f))
278
+ .sort((a, b) => a.line - b.line)
279
+ },
280
+ /** Applied, unchanged migrations (baseline) are history, not a change under review: counted, not reported. */
281
+ finalize(findings, ctx = {}) {
282
+ const applied = findings.filter((f) => f.applied)
283
+ const byRule = {}
284
+ for (const f of applied) byRule[f.rule] = (byRule[f.rule] || 0) + 1
285
+ const n = Object.keys(ctx.baseline?.migrations?.applied || {}).length
286
+ return {
287
+ findings: findings.filter((f) => !f.applied),
288
+ measurements: n ? { appliedBaselineFiles: n, appliedBaselined: applied.length, appliedBaselinedByRule: byRule } : {},
289
+ }
290
+ },
291
+ rules: Object.fromEntries(Object.entries(RULE_TEXT).map(([k, v]) => [k, { severity: SEVERITY[k], summary: v.split(' FIX: ')[0], fix: (v.split(' FIX: ')[1] || '').trim() || null }])),
292
+ negativeControls: [
293
+ { id: 'migrations/NC-1', file: 'db/migrations/0042_orders.sql', expect: 'DROP_COLUMN', content: 'ALTER TABLE orders DROP COLUMN legacy_total;\n' },
294
+ { id: 'migrations/NC-2', file: 'prisma/migrations/20260901_add_region/migration.sql', expect: 'ADD_NOT_NULL_NO_DEFAULT', content: '-- AlterTable\nALTER TABLE "Order" ADD COLUMN "region" TEXT NOT NULL;\n' },
295
+ { id: 'migrations/NC-3', file: 'src/migrations/1727000000000-Amount.ts', expect: 'ALTER_COLUMN_TYPE', content: "export class Amount1727000000000 implements MigrationInterface {\n async up(queryRunner: QueryRunner) {\n await queryRunner.query(`ALTER TABLE invoices ALTER COLUMN amount TYPE numeric(12,2)`)\n }\n async down(queryRunner: QueryRunner) {\n await queryRunner.query(`ALTER TABLE invoices ALTER COLUMN amount TYPE integer`)\n }\n}\n" },
296
+ { id: 'migrations/NC-4', file: 'migrations/20260901_users.js', expect: 'MISSING_DOWN', content: "exports.up = function (knex) {\n return knex.schema.createTable('users', (t) => { t.increments('id') })\n}\n" },
297
+ { id: 'migrations/NC-5', file: 'alembic/versions/3f2a_drop_notes.py', expect: 'DROP_TABLE', content: "def upgrade():\n op.drop_table('notes')\n\n\ndef downgrade():\n op.create_table('notes', sa.Column('id', sa.Integer()))\n" },
298
+ { id: 'migrations/NC-7', file: 'prisma/migrations/20260904_orgs/migration.sql', expect: 'DROP_COLUMN', content: 'ALTER TABLE "Group" ADD COLUMN team_role TEXT;\nALTER TABLE "Team" DROP COLUMN legacy_role;\n' },
299
+ { id: 'migrations/NC-6', file: 'migrations/20260902_tenant.js', expect: 'ADD_NOT_NULL_NO_DEFAULT', content: "exports.up = (knex) => knex.schema.alterTable('orders', (t) => {\n t.string('tenant_id').notNullable()\n})\nexports.down = (knex) => knex.schema.alterTable('orders', (t) => { t.dropColumn('tenant_id') })\n" },
300
+ { id: 'migrations/NC-8', file: 'db/migrations/0045_region_required.sql', expect: 'SET_NOT_NULL', content: '-- migrate:up\nALTER TABLE orders ALTER COLUMN region SET NOT NULL;\n\n-- migrate:down\nALTER TABLE orders ALTER COLUMN region DROP NOT NULL;\n' },
301
+ { id: 'migrations/NC-9', file: 'migrations/20260905_roles.js', expect: 'DOWN_INCOMPLETE', content: "exports.up = async (knex) => {\n await knex.schema.createTable('roles', (t) => { t.increments('id') })\n await knex.schema.createTable('grants', (t) => { t.increments('id') })\n}\nexports.down = async (knex) => {\n await knex.schema.dropTable('grants')\n}\n" },
302
+ { id: 'migrations/NC-10', file: 'migrations/20260906_teams.js', expect: 'DOWN_ORDER', content: "exports.up = async (knex) => {\n await knex.schema.createTable('teams', (t) => { t.increments('id') })\n await knex.schema.createTable('members', (t) => { t.increments('id') })\n}\nexports.down = async (knex) => {\n await knex.schema.dropTable('teams')\n await knex.schema.dropTable('members')\n}\n" },
303
+ ],
304
+ // Positive controls: `rule` names the rule each is the nearest clean look-alike for.
305
+ positiveControls: [
306
+ { id: 'migrations/PC-6', rule: 'SET_NOT_NULL', file: 'db/migrations/0046_region_optional.sql', content: '-- migrate:up\nALTER TABLE orders ALTER COLUMN region DROP NOT NULL;\n\n-- migrate:down\nALTER TABLE orders ALTER COLUMN region SET DEFAULT \'IN\';\n' },
307
+ { id: 'migrations/PC-7', rule: 'DOWN_INCOMPLETE', file: 'migrations/20260905_roles.js', content: "exports.up = async (knex) => {\n await knex.schema.createTable('roles', (t) => { t.increments('id') })\n await knex.schema.createTable('grants', (t) => { t.increments('id') })\n}\nexports.down = async (knex) => {\n await knex.schema.dropTable('grants')\n await knex.schema.dropTable('roles')\n}\n" },
308
+ { id: 'migrations/PC-8', rule: 'MISSING_DOWN', file: 'migrations/20260901_users.js', content: "exports.up = function (knex) {\n return knex.schema.createTable('users', (t) => { t.increments('id') })\n}\nexports.down = function (knex) {\n return knex.schema.dropTable('users')\n}\n" },
309
+ { id: 'migrations/PC-9', rule: 'ALTER_COLUMN_TYPE', file: 'db/migrations/0047_amount_default.sql', content: '-- migrate:up\nALTER TABLE invoices ALTER COLUMN amount SET DEFAULT 0;\n\n-- migrate:down\nALTER TABLE invoices ALTER COLUMN amount DROP DEFAULT;\n' },
310
+ { id: 'migrations/PC-1', rule: 'ADD_NOT_NULL_NO_DEFAULT', file: 'db/migrations/0043_safe.sql', content: '-- migrate:up\nCREATE TABLE audit_log (id BIGSERIAL PRIMARY KEY, note TEXT NOT NULL);\nALTER TABLE orders ADD COLUMN region TEXT NOT NULL DEFAULT \'IN\';\n\n-- migrate:down\nALTER TABLE orders DROP COLUMN region;\nDROP TABLE audit_log;\n' },
311
+ { id: 'migrations/PC-2', rule: 'DOWN_ORDER', file: 'src/migrations/1727000000001-Audit.ts', content: "export class Audit1727000000001 implements MigrationInterface {\n async up(queryRunner: QueryRunner) {\n for (const t of ['audit_a', 'audit_b']) {\n await queryRunner.query(`CREATE TABLE ${t} (id serial PRIMARY KEY)`)\n }\n }\n async down(queryRunner: QueryRunner) {\n await queryRunner.query(`DROP TABLE audit_b`)\n await queryRunner.query(`DROP TABLE audit_a`)\n }\n}\n" },
312
+ { id: 'migrations/PC-3', rule: 'DROP_COLUMN', file: 'db/migrations/0044_contract.sql', content: '-- fini-proof: allow DROP_COLUMN CHG-812 column unused since release 41, backup taken\nALTER TABLE orders DROP COLUMN legacy_total;\n' },
313
+ { id: 'migrations/PC-5', rule: 'DROP_TABLE', file: 'prisma/migrations/20260903_orgs/migration.sql', content: 'ALTER TABLE "Group" ADD COLUMN temp_team_id INTEGER;\nUPDATE "Group" SET temp_team_id = 1;\nALTER TABLE "Group" DROP COLUMN temp_team_id;\nCREATE TABLE tmp_map (id INT);\nDROP TABLE tmp_map;\n' },
314
+ { id: 'migrations/PC-4', file: 'alembic/versions/4b1c_region.py', content: "def upgrade():\n op.add_column('orders', sa.Column('region', sa.String(), nullable=False, server_default='IN'))\n\n\ndef downgrade():\n op.drop_column('orders', 'region')\n" },
315
+ ],
316
+ }
@@ -0,0 +1,88 @@
1
+ // fini-proof engine: secrets — committed secret material.
2
+ // Adapter over the ported Finipe gate (./vendor/secrets-core.mjs, provenance there). Additions for customer
3
+ // repos (not in the Finipe gate, marked NEW): GitHub tokens, Google API keys, OpenAI/Anthropic-style keys,
4
+ // and a generic `password = "…"` literal in source code.
5
+ import { scanContentForSecrets, BINARY_EXT_RE, lineOf } from './vendor/secrets-core.mjs'
6
+
7
+ // NEW patterns (fini-proof 0.1). Each has a negative control below.
8
+ const EXTRA = [
9
+ ['github-token', /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{50,}\b/g],
10
+ ['google-api-key', /\bAIza[0-9A-Za-z_-]{35}\b/g],
11
+ ['llm-api-key', /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{32,}\b/g],
12
+ ]
13
+ const SRC_RE = /\.(js|jsx|ts|tsx|mjs|cjs|py|rb|go|java|kt|cs|php|ya?ml|json|properties|toml|ini|cfg|conf)$/i
14
+ const PASSWORD_LITERAL_RE = /\b([A-Za-z_]*(?:password|passwd|secret|api_?key|access_?token)[A-Za-z_]*)\s*[:=]\s*['"]([^'"\s]{8,})['"]/gi
15
+ // A value that names its own kind ('gh-secret', 'my-password', 'token-123') is a fixture label, not a credential.
16
+ const PLACEHOLDER_RE = /change.?me|example|placeholder|dummy|fake|sample|your[-_]|xxx|\*\*\*|<|\$\{|process\.env|os\.environ|test|secret|passw(or)?d|token/i
17
+
18
+ // Reports, CI logs and evidence files must not re-leak the secret: keep 4 leading + 2 trailing characters.
19
+ const redact = (m) => m.replace(/[A-Za-z0-9_\-+/=.]{9,}/g, (t) => `${t.slice(0, 4)}…${t.slice(-2)}`)
20
+
21
+ function extra(rel, content) {
22
+ const out = []
23
+ for (const [kind, re] of EXTRA) {
24
+ re.lastIndex = 0
25
+ let m
26
+ while ((m = re.exec(content)) !== null) out.push({ kind, line: lineOf(content, m.index), match: m[0].slice(0, 12) + '…' })
27
+ }
28
+ if (SRC_RE.test(rel)) {
29
+ PASSWORD_LITERAL_RE.lastIndex = 0
30
+ let m
31
+ while ((m = PASSWORD_LITERAL_RE.exec(content)) !== null) {
32
+ if (PLACEHOLDER_RE.test(m[2])) continue
33
+ out.push({ kind: 'hardcoded-credential', line: lineOf(content, m.index), match: `${m[1]} = <redacted>` })
34
+ }
35
+ }
36
+ return out
37
+ }
38
+
39
+ export default {
40
+ id: 'secrets',
41
+ version: '0.1.0',
42
+ title: 'Committed secrets',
43
+ provenance: 'finipe scripts/ci/check-secrets-and-log-redaction.mjs part (a) @ 4ac53b367; 4 patterns new',
44
+ appliesTo: (rel) => !BINARY_EXT_RE.test(rel) && !/(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|poetry\.lock)$/.test(rel),
45
+ scanFile(rel, content) {
46
+ const seen = new Set()
47
+ return [...scanContentForSecrets(rel, content), ...extra(rel, content)]
48
+ .filter((f) => { const k = `${f.line}:${f.kind}`; if (seen.has(k)) return false; seen.add(k); return true })
49
+ .map((f) => ({
50
+ line: f.line,
51
+ rule: `SECRET_${f.kind.toUpperCase().replace(/-/g, '_')}`,
52
+ severity: 'high',
53
+ message: `${f.kind} committed to the repository (${redact(f.match)}). Anyone with read access has it. FIX: rotate it now, then load it from the environment or a secret manager.`,
54
+ }))
55
+ },
56
+ rules: Object.fromEntries(['aws-access-key', 'private-key-block', 'slack-token', 'stripe-live-key', 'sendgrid-key', 'jwt-assigned-to-config', 'env-secret-assignment', 'github-token', 'google-api-key', 'llm-api-key', 'hardcoded-credential']
57
+ .map((k) => [`SECRET_${k.toUpperCase().replace(/-/g, '_')}`, { severity: 'high', summary: `${k} committed to the repository — anyone with read access has it`, fix: 'rotate it now, then load it from the environment or a secret manager' }])),
58
+ // Planted material is assembled at runtime so this source file never contains a secret-shaped literal.
59
+ negativeControls: [
60
+ { id: 'secrets/NC-1', file: 'nc/config.js', expect: 'SECRET_AWS_ACCESS_KEY', content: `const key = '${'AKIA' + 'Q3VZ7K2M9P4XWT6B'}'\n` },
61
+ { id: 'secrets/NC-2', file: 'nc/.env', expect: 'SECRET_ENV_SECRET_ASSIGNMENT', content: `DB_PASSWORD=${'n9!Kq' + '2vLx8wZ'}\n` },
62
+ { id: 'secrets/NC-3', file: 'nc/deploy.pem.txt', expect: 'SECRET_PRIVATE_KEY_BLOCK', content: `-----BEGIN ${'RSA PRIVATE'} KEY-----\nMIIE\n` },
63
+ { id: 'secrets/NC-4', file: 'nc/gh.py', expect: 'SECRET_GITHUB_TOKEN', content: `TOKEN = "${'ghp_' + 'a'.repeat(18) + 'B7'.repeat(9)}"\n` },
64
+ { id: 'secrets/NC-5', file: 'nc/db.py', expect: 'SECRET_HARDCODED_CREDENTIAL', content: `db_password = "${'Tr0ub4' + 'dor&3x'}"\n` },
65
+ { id: 'secrets/NC-6', file: 'nc/notify.js', expect: 'SECRET_SLACK_TOKEN', content: `const slack = '${'xox' + 'b-'}4821907736-${'Qm7Zt2Lp9Xv4'}'\n` },
66
+ { id: 'secrets/NC-7', file: 'nc/billing.js', expect: 'SECRET_STRIPE_LIVE_KEY', content: `const stripe = '${'sk_' + 'live_'}${'51Hq8ZKp3Rt7Vw2Nx9'}'\n` },
67
+ { id: 'secrets/NC-8', file: 'nc/mail.js', expect: 'SECRET_SENDGRID_KEY', content: `const sg = '${'SG' + '.'}${'q7Lm2Xv9Pz4Kt8Rw1Nb6'}.${'Yc3Hd5Jf7Gs9Ua2Ei4Ok'}'\n` },
68
+ { id: 'secrets/NC-9', file: 'nc/client.js', expect: 'SECRET_JWT_ASSIGNED_TO_CONFIG', content: `const token = '${'ey' + 'J'}hbGciOiJIUzI1NiJ9.${'eyJzdWIiOiIxMjM0In0'}.${'Zm9vYmFyYmF6cXV4'}'\n` },
69
+ { id: 'secrets/NC-10', file: 'nc/maps.js', expect: 'SECRET_GOOGLE_API_KEY', content: `const maps = '${'AI' + 'za'}${'SyB7qL2mX9vZ4pT8kW3nR6cY1hJ5dF0gS2a'}'\n` },
70
+ { id: 'secrets/NC-11', file: 'nc/llm.py', expect: 'SECRET_LLM_API_KEY', content: `CLIENT_KEY = "${'sk-' + 'ant-'}${'api03-Q7mL2xV9pZ4kT8wR3nC6yH1jD5fG0sE2uB'}"\n` },
71
+ ],
72
+ // Positive controls: `rule` names the rule each is the nearest clean look-alike for (a shorter prefix, a test-mode
73
+ // key, a public key, a placeholder, an env read). A detector that grows too lax for that neighbour goes red here.
74
+ positiveControls: [
75
+ { id: 'secrets/PC-1', rule: 'SECRET_ENV_SECRET_ASSIGNMENT', file: 'pc/.env.example', content: 'DB_PASSWORD=change-me\nAPI_TOKEN=\nMAX_TOKEN_LIMIT=4000\n' },
76
+ { id: 'secrets/PC-3', file: 'pc/helpers.js', content: "const cfg = { webhookSecret: 'gh-secret', password: 'my-password' }\n" },
77
+ { id: 'secrets/PC-2', rule: 'SECRET_HARDCODED_CREDENTIAL', file: 'pc/config.js', content: "const password = process.env.DB_PASSWORD\nconst apiKey = '${API_KEY}'\n" },
78
+ { id: 'secrets/PC-4', rule: 'SECRET_AWS_ACCESS_KEY', file: 'pc/aws.js', content: "// an access key id starts with the 4-letter prefix and has 16 more characters\nconst prefix = 'AKIA'\nconst id = process.env.AWS_ACCESS_KEY_ID\n" },
79
+ { id: 'secrets/PC-5', rule: 'SECRET_PRIVATE_KEY_BLOCK', file: 'pc/signing.pem.txt', content: '-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE\n-----END PUBLIC KEY-----\n' },
80
+ { id: 'secrets/PC-6', rule: 'SECRET_GITHUB_TOKEN', file: 'pc/gh.py', content: 'TOKEN = os.environ["GITHUB_TOKEN"] # classic tokens start with ghp_\n' },
81
+ { id: 'secrets/PC-7', rule: 'SECRET_SLACK_TOKEN', file: 'pc/notify.js', content: "const slack = process.env.SLACK_BOT_TOKEN // bot tokens start with xoxb-\n" },
82
+ { id: 'secrets/PC-8', rule: 'SECRET_STRIPE_LIVE_KEY', file: 'pc/billing.js', content: "const stripe = 'sk_test_51Hq8ZKp3Rt7Vw2Nx9'\n" },
83
+ { id: 'secrets/PC-9', rule: 'SECRET_SENDGRID_KEY', file: 'pc/mail.js', content: "const sg = process.env.SENDGRID_API_KEY // format SG.<id>.<secret>\n" },
84
+ { id: 'secrets/PC-10', rule: 'SECRET_JWT_ASSIGNED_TO_CONFIG', file: 'pc/client.js', content: "const header = 'eyJhbGciOiJIUzI1NiJ9'\nconst token = req.headers.authorization\n" },
85
+ { id: 'secrets/PC-11', rule: 'SECRET_GOOGLE_API_KEY', file: 'pc/maps.js', content: "const maps = process.env.GOOGLE_MAPS_KEY // keys start with AIza\n" },
86
+ { id: 'secrets/PC-12', rule: 'SECRET_LLM_API_KEY', file: 'pc/llm.py', content: 'CLIENT_KEY = os.environ["ANTHROPIC_API_KEY"] # keys start with sk-ant-\n' },
87
+ ],
88
+ }