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.
package/src/cli.mjs ADDED
@@ -0,0 +1,258 @@
1
+ // fini-proof command line. Exit codes: 0 PASS · 1 FAIL · 2 usage · 3 NOT_MEASURED · 4 licence refused.
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { check, ENGINES, EXIT, prove, scanWith, TOOL_VERSION, sha256 } from './runner.mjs'
5
+ import { loadBaseline, listFiles, repoInfo } from './files.mjs'
6
+ import { toSarif, toText } from './report.mjs'
7
+ import { entitlement, verifyLicence, findLicenceKey } from './licence.mjs'
8
+ import { exportUsage, readUsage, recordRun } from './usage.mjs'
9
+ import { serveMcp } from './mcp.mjs'
10
+ import { runHook } from './hooks.mjs'
11
+ import { DEFAULT_API, gzipWanted, uploadEvidence } from './upload.mjs'
12
+ import { readEvidenceFile, snapshotOf } from './evidence.mjs'
13
+ import { applyStaleness, deriveStates, stateDocument, stateText } from './state.mjs'
14
+
15
+ const HELP = `fini-proof ${TOOL_VERSION} — proof-based code verification
16
+
17
+ fini-proof check [--base <ref>] [--format text|json|sarif] [--fail-on high|medium|low|none]
18
+ [--sarif-out <file>] [--json-out <file>] [--evidence <file>] [--engine <id>]... [--only <file>]... [--root <dir>]
19
+ [--upload [--repository <owner/name>] [--upload-gzip]] send the evidence to your Developer workspace
20
+ (FINI_PROOF_API_KEY; FINI_PROOF_API, default ${DEFAULT_API})
21
+ fini-proof state [--against <evidence.json>] [--format text|json] [check options]
22
+ per property: PROVEN · FAILED · NOT_MEASURED · STALE · ADVISORY. Without --against it runs the
23
+ check now; with --against it runs nothing and marks STALE what changed since that evidence
24
+ fini-proof evidence verify <evidence.json> re-check an evidence file's digest (exit 1 = edited)
25
+ fini-proof prove --engine <id> --file <path> [--line <n>] re-run one finding (exit 1 = still there)
26
+ fini-proof negctl <control-id> | --all show a check can fail (planted defect → caught)
27
+ fini-proof baseline [--reset] write the skip ratchet baseline (can only go down)
28
+ fini-proof usage [--export <file>] local usage meter (runs, repos, seats)
29
+ fini-proof licence [status] licence / trial status
30
+ fini-proof mcp MCP server on stdio (tools: check, explain_finding, list_rules)
31
+ fini-proof hook claude Claude Code hook (Stop / PostToolUse): stdin JSON, exit 2 blocks
32
+
33
+ Verdicts: PASS · FAIL · NOT_MEASURED (a check that could not run is never a PASS).
34
+ Maker attestation: FINI_PROOF_MAKER_KIND (human|agent) / _ID / _MODEL / _SESSION, else the git author (kind unknown).
35
+ gitleaks: used when on PATH (FINI_PROOF_GITLEAKS=<path> | off); never downloaded.
36
+ Engines: ${ENGINES.map((e) => e.id).join(', ')}`
37
+
38
+ function parse(argv) {
39
+ const o = { _: [], engine: [] }
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const a = argv[i]
42
+ if (!a.startsWith('--')) { o._.push(a); continue }
43
+ const [k, inline] = a.slice(2).split('=')
44
+ const flag = ['all', 'reset', 'help', 'version', 'upload', 'upload-gzip'].includes(k)
45
+ const v = flag ? true : (inline ?? argv[++i])
46
+ if (!flag && v === undefined) throw new Error(`--${k} needs a value`)
47
+ if (k === 'engine') o.engine.push(v)
48
+ else if (k === 'only') (o.only ||= []).push(v)
49
+ else o[k.replace(/-(\w)/g, (_, x) => x.toUpperCase())] = v
50
+ }
51
+ return o
52
+ }
53
+
54
+ const out = (s) => process.stdout.write(s.endsWith('\n') ? s : s + '\n')
55
+ const err = (s) => process.stderr.write(s.endsWith('\n') ? s : s + '\n')
56
+
57
+ /**
58
+ * The one licensed check path — used by `check`, the MCP `check` tool and the Claude Code hook, so all three enforce
59
+ * the same licence, write the same evidence and meter the same way. Returns { rc, result?, ent?, evidencePath?, error? }.
60
+ */
61
+ export function executeCheck({ root, base, failOn, engines = [], only, licence, evidence, sarifOut, jsonOut, maker }) {
62
+ const unknown = engines.filter((e) => !ENGINES.some((x) => x.id === e))
63
+ if (unknown.length) return { rc: EXIT.USAGE, error: `unknown engine(s): ${unknown.join(', ')}` }
64
+ const info = repoInfo(root)
65
+ const repoDigest = info.rootCommit ? sha256(info.rootCommit) : sha256(root)
66
+ const ent = entitlement(root, repoDigest, { explicit: licence })
67
+ if (!ent.ok) return { rc: EXIT.LICENCE, error: `VERDICT: NOT_MEASURED — licence: ${ent.reason}` }
68
+ let result
69
+ try { result = check(root, { base, failOn, engines, only, maker }) } catch (e) { return { rc: EXIT.USAGE, error: `fini-proof: ${e.message}` } }
70
+ result.entitlement = ent.mode === 'licence' ? { mode: 'licence', org: ent.org, seats: ent.seats, expiry: ent.expiry, licenceId: ent.licenceId } : { mode: 'trial', daysLeft: ent.daysLeft, trialEnds: ent.trialEnds }
71
+ const evidencePath = path.resolve(root, evidence || path.join('.fini-proof', 'runs', `${result.runId}.json`))
72
+ mkdirSync(path.dirname(evidencePath), { recursive: true })
73
+ writeFileSync(evidencePath, JSON.stringify(result, null, 2) + '\n')
74
+ if (sarifOut) writeFileSync(path.resolve(sarifOut), JSON.stringify(toSarif(result), null, 2) + '\n')
75
+ if (jsonOut) writeFileSync(path.resolve(jsonOut), JSON.stringify(result, null, 2) + '\n')
76
+ recordRun(result, ent)
77
+ return { rc: EXIT[result.verdict], result, ent, evidencePath }
78
+ }
79
+
80
+ function cmdCheck(o) {
81
+ const root = path.resolve(o.root || process.cwd())
82
+ const format = o.format || 'text'
83
+ if (!['text', 'json', 'sarif'].includes(format)) { err(`--format must be text|json|sarif`); return EXIT.USAGE }
84
+ if (o.uploadGzip && !o.upload) { err('--upload-gzip needs --upload'); return EXIT.USAGE }
85
+ const x = executeCheck({ root, base: o.base, failOn: o.failOn, engines: o.engine, only: o.only, licence: o.licence, evidence: o.evidence, sarifOut: o.sarifOut, jsonOut: o.jsonOut })
86
+ if (x.error) { err(x.error); return x.rc }
87
+ const { result, ent, evidencePath } = x
88
+ if (format === 'json') out(JSON.stringify(result, null, 2))
89
+ else if (format === 'sarif') out(JSON.stringify(toSarif(result), null, 2))
90
+ else {
91
+ out(toText(result, { evidencePath: path.relative(process.cwd(), evidencePath) }))
92
+ out(ent.mode === 'trial' ? `trial: ${ent.daysLeft} day(s) left (1 repository)` : `licensed to ${ent.org} (${ent.seats} seats, until ${ent.expiry})`)
93
+ }
94
+ if (!o.upload) return x.rc
95
+ // The upload never changes the verdict or the exit code: it reports on stderr and returns x.rc either way.
96
+ return uploadEvidence(result, { root, repository: o.repository, gzip: gzipWanted(o.uploadGzip) }).then((u) => {
97
+ if (u.ok) err(`uploaded to Fini Proof: run ${u.runId ?? '(id not returned)'}${u.status === 200 ? ' (already recorded)' : ''}`)
98
+ else err(`warning: upload failed — ${String(u.error).replace(/\.+$/, '')}. The verdict above stands; the evidence is at ${path.relative(process.cwd(), evidencePath)}.`)
99
+ return x.rc
100
+ })
101
+ }
102
+
103
+ function cmdProve(o) {
104
+ const root = path.resolve(o.root || process.cwd())
105
+ const engine = ENGINES.find((e) => e.id === o.engine[0])
106
+ if (!engine || !o.file) { err('usage: fini-proof prove --engine <id> --file <path> [--line <n>]'); return EXIT.USAGE }
107
+ const line = o.line ? Number(o.line) : null
108
+ const p = prove(root, engine.id, o.file, line)
109
+ if (p.status !== 'OK') { err(`NOT_MEASURED — ${p.reason}`); return EXIT.NOT_MEASURED }
110
+ const { hits, content } = p
111
+ const src = content.split('\n')
112
+ out(`proof · engine ${engine.id} ${engine.version} · ${o.file}${line ? `:${line}` : ''} · sha256 ${p.sha.slice(0, 16)}…`)
113
+ if (!hits.length) { out('REPRODUCED: no — the defect is not present at this location (fixed).'); return EXIT.PASS }
114
+ for (const f of hits) {
115
+ out(`\n ${f.rule} (${f.severity}) at ${o.file}:${f.line}`)
116
+ for (let i = Math.max(1, f.line - 1); i <= Math.min(src.length, f.line + 3); i++) out(` ${String(i).padStart(4)} ${i === f.line ? '>' : ' '} ${src[i - 1]}`)
117
+ out(` ${f.message}`)
118
+ }
119
+ out(`\nREPRODUCED: yes — ${hits.length} finding(s). Negative controls for this engine: ${engine.negativeControls.map((c) => c.id).join(', ')}`)
120
+ return EXIT.FAIL
121
+ }
122
+
123
+ function cmdNegctl(o) {
124
+ const all = ENGINES.flatMap((e) => e.negativeControls.map((c) => ({ e, c })))
125
+ const pick = o.all ? all : all.filter(({ c }) => c.id === o._[1])
126
+ if (!pick.length) { err(`unknown control. Known: ${all.map(({ c }) => c.id).join(', ')}`); return EXIT.USAGE }
127
+ let failed = 0
128
+ let skipped = 0
129
+ const pre = new Map()
130
+ for (const { e, c } of pick) {
131
+ // An orchestrated engine whose binary is absent is SKIPPED (not caught, not missed); the count says so.
132
+ if (e.precondition && !pre.has(e.id)) pre.set(e.id, e.precondition())
133
+ const pc = pre.get(e.id) || {}
134
+ if (pc.notApplicable || pc.unmeasured) { skipped++; out(`SKIPPED ${c.id} ${e.id} is ${pc.notApplicable ? 'not applicable' : 'NOT_MEASURED'}: ${pc.notApplicable || pc.unmeasured}`); continue }
135
+ let findings = []
136
+ let error = null
137
+ try { ({ findings } = scanWith(e, [{ rel: c.file, content: c.content }], { root: process.cwd(), baseline: {}, ...(c.ctx || {}) })) } catch (x) { error = x.message }
138
+ const caught = !error && findings.some((f) => f.rule === c.expect)
139
+ if (!caught) failed++
140
+ out(`${caught ? 'CAUGHT ' : 'MISSED '} ${c.id} planted ${c.expect} in ${c.file}${error ? ` (engine error: ${error})` : ''}${o.all ? '' : `\n---\n${c.content}---\ngot: [${findings.map((f) => `${f.rule}@${f.line}`).join(', ')}]`}`)
141
+ }
142
+ const ran = pick.length - skipped
143
+ const skipNote = skipped ? `; ${skipped} skipped (engine not available here — skipped is not caught)` : ''
144
+ out(failed ? `${failed} negative control(s) MISSED — those checks cannot fail${skipNote}` : `${ran}/${ran} negative control(s) caught — each check is shown able to fail${skipNote}`)
145
+ if (!failed && ran === 0) return EXIT.NOT_MEASURED
146
+ return failed ? EXIT.FAIL : EXIT.PASS
147
+ }
148
+
149
+ function cmdState(o) {
150
+ const root = path.resolve(o.root || process.cwd())
151
+ const format = o.format || 'text'
152
+ if (!['text', 'json'].includes(format)) { err('--format must be text|json'); return EXIT.USAGE }
153
+ let doc
154
+ let evidencePath
155
+ let mode
156
+ if (o.against) {
157
+ // Read-time derivation (SSOT PART 5): nothing is run; the file must verify before a single state is derived from it.
158
+ evidencePath = path.resolve(o.against)
159
+ const r = readEvidenceFile(evidencePath)
160
+ if (!r.ok) { err(`NOT_MEASURED — ${o.against}: ${r.reason}`); return EXIT.NOT_MEASURED }
161
+ doc = r.doc
162
+ mode = 'against'
163
+ } else {
164
+ const x = executeCheck({ root, base: o.base, failOn: o.failOn, engines: o.engine, only: o.only, licence: o.licence, evidence: o.evidence })
165
+ if (x.error) { err(x.error); return x.rc }
166
+ doc = x.result
167
+ evidencePath = x.evidencePath
168
+ mode = 'run'
169
+ }
170
+ const rows = deriveStates(doc)
171
+ if (mode === 'against') applyStaleness(rows, doc, root)
172
+ const sd = stateDocument(doc, rows, { mode, evidencePath, root })
173
+ out(format === 'json' ? JSON.stringify(sd, null, 2) : stateText(sd))
174
+ return EXIT.PASS
175
+ }
176
+
177
+ function cmdEvidence(o) {
178
+ const file = o._[2]
179
+ if (o._[1] !== 'verify' || !file) { err('usage: fini-proof evidence verify <evidence.json>'); return EXIT.USAGE }
180
+ const r = readEvidenceFile(path.resolve(file))
181
+ if (!r.ok) { out(`INVALID ${file}: ${r.reason}${r.code ? ` (${r.code})` : ''}`); return r.code === 'UNREADABLE' || r.code === 'NOT_EVIDENCE' ? EXIT.USAGE : EXIT.FAIL }
182
+ const d = r.doc
183
+ const snap = snapshotOf(d)
184
+ const m = d.attestation?.maker
185
+ out(`VALID ${file}: ${d.schema} · run ${d.runId} · verdict ${d.verdict} · sha256 ${d.evidenceDigest.slice(0, 16)}…`)
186
+ out(` tree ${snap.tree_hash || `(none: ${snap.reason})`} · maker ${m ? `${m.kind}:${m.id}${m.model ? ` (${m.model})` : ''} [${m.source}]` : '(not recorded in @1)'}`)
187
+ return EXIT.PASS
188
+ }
189
+
190
+ function cmdBaseline(o) {
191
+ const root = path.resolve(o.root || process.cwd())
192
+ const engine = ENGINES.find((e) => e.id === 'skip-ratchet')
193
+ const listed = listFiles(root)
194
+ if (listed.unmeasured) { err(`NOT_MEASURED — ${listed.unmeasured}`); return EXIT.NOT_MEASURED }
195
+ const files = listed.files.filter((f) => engine.appliesTo(f) && !f.startsWith('.fini-proof/')).map((rel) => ({ rel, content: readFileSync(path.join(root, rel), 'utf8') }))
196
+ const { findings } = scanWith(engine, files, { root, baseline: {} })
197
+ const now = {}
198
+ for (const f of findings) if (f.rule === 'SKIP_RATCHET') now[f.file] = (now[f.file] || 0) + 1
199
+ const prev = loadBaseline(root)['skip-ratchet'] || {}
200
+ const next = {}
201
+ const raised = []
202
+ for (const [f, n] of Object.entries(now)) {
203
+ if (!o.reset && f in prev && n > prev[f]) raised.push(`${f} ${prev[f]}→${n}`)
204
+ next[f] = o.reset || !(f in prev) ? n : Math.min(n, prev[f])
205
+ }
206
+ // migrations already applied at adoption (path → sha256): history, not a change under review
207
+ const mig = ENGINES.find((e) => e.id === 'migrations')
208
+ const applied = Object.fromEntries(listed.files.filter((f) => mig.appliesTo(f) && !f.startsWith('.fini-proof/')).sort()
209
+ .map((rel) => [rel, sha256(readFileSync(path.join(root, rel), 'utf8'))]))
210
+ const p = path.join(root, '.fini-proof', 'baseline.json')
211
+ mkdirSync(path.dirname(p), { recursive: true })
212
+ writeFileSync(p, JSON.stringify({ ...loadBaseline(root), 'skip-ratchet': next, migrations: { applied } }, null, 2) + '\n')
213
+ out(`wrote ${path.relative(process.cwd(), p)} — ${Object.values(next).reduce((a, b) => a + b, 0)} tolerated skip(s) in ${Object.keys(next).length} file(s); ${Object.keys(applied).length} applied migration(s) recorded`)
214
+ if (raised.length) out(`NOT raised (the ratchet only goes down; use --reset with a reviewed commit): ${raised.join(', ')}`)
215
+ return EXIT.PASS
216
+ }
217
+
218
+ function cmdUsage(o) {
219
+ const found = findLicenceKey(process.cwd(), o.licence)
220
+ const lic = found ? verifyLicence(found.key) : null
221
+ const s = exportUsage(o.export ? path.resolve(o.export) : null, lic?.ok ? lic.payload.seats : null)
222
+ out(`runs ${s.runs} · repos ${s.repos} · distinct authors ${s.distinctAuthors}${s.seats ? ` / ${s.seats} seats${s.overSeats ? ' (OVER)' : ''}` : ''} · rows ${readUsage().length}`)
223
+ for (const [m, v] of Object.entries(s.months)) out(` ${m}: ${v.runs} run(s), ${v.repos} repo(s), ${v.distinctAuthors} author(s) ${JSON.stringify(v.verdicts)}`)
224
+ if (o.export) out(`exported ${o.export} (digest ${s.digest.slice(0, 16)}…)`)
225
+ return EXIT.PASS
226
+ }
227
+
228
+ function cmdLicence(o) {
229
+ const root = path.resolve(o.root || process.cwd())
230
+ const found = findLicenceKey(root, o.licence)
231
+ if (!found) { out('no licence key found — trial mode (1 repository, 14 days). Set FINI_PROOF_LICENCE or put the key in .fini-proof/licence.key'); return EXIT.PASS }
232
+ const v = verifyLicence(found.key)
233
+ if (!v.ok) { out(`licence from ${found.source}: REFUSED — ${v.reason}`); return EXIT.LICENCE }
234
+ out(`licence from ${found.source}: VALID — ${v.payload.org}, ${v.payload.seats} seats, until ${v.payload.expiry}`)
235
+ return EXIT.PASS
236
+ }
237
+
238
+ export function main(argv) {
239
+ let o
240
+ try { o = parse(argv) } catch (e) { err(e.message); return EXIT.USAGE }
241
+ const cmd = o._[0]
242
+ if (o.version || cmd === 'version') { out(TOOL_VERSION); return 0 }
243
+ if (!cmd || o.help || cmd === 'help') { out(HELP); return cmd ? 0 : EXIT.USAGE }
244
+ switch (cmd) {
245
+ case 'check': return cmdCheck(o)
246
+ case 'prove': return cmdProve(o)
247
+ case 'state': return cmdState(o)
248
+ case 'evidence': return cmdEvidence(o)
249
+ case 'negctl': return cmdNegctl(o)
250
+ case 'baseline': return cmdBaseline(o)
251
+ case 'usage': return cmdUsage(o)
252
+ case 'licence': case 'license': return cmdLicence(o)
253
+ case 'mcp': return serveMcp()
254
+ case 'hook': return runHook(o._[1])
255
+ default: err(`unknown command "${cmd}"\n\n${HELP}`); return EXIT.USAGE
256
+ }
257
+ }
258
+
@@ -0,0 +1,133 @@
1
+ // fini-proof engine: fail-open — CI steps and scripts that report success when they failed.
2
+ // RAW_ENTRYPOINT_GUARD reuses the ported Finipe detector (./vendor/entrypoint-guard-core.mjs, provenance there).
3
+ // The CI/shell/catch rules are NEW in fini-proof: in Finipe this class is enforced by several repo-specific gates
4
+ // (exit-code discipline in scripts/ci/ci-local.mjs, NOT_MEASURED exit 3 convention, the $? checks in
5
+ // scripts/ci/runner/*.sh); here it is generalised into one text rule set for any repo.
6
+ import { rawGuards } from './vendor/entrypoint-guard-core.mjs'
7
+ import { maskStringsAndComments } from './vendor/vacuous-spec-core.mjs'
8
+
9
+ const CI_FILE_RE = /(^|\/)(\.github\/workflows\/[^/]+\.ya?ml|\.gitlab-ci\.yml|bitbucket-pipelines\.yml|Jenkinsfile|azure-pipelines\.yml|\.circleci\/config\.yml|Makefile)$/
10
+ const SHELL_RE = /\.(sh|bash)$/
11
+ const SCRIPT_DIR_RE = /(^|\/)(scripts?|ci|tools|bin|\.github)\//
12
+ const JS_RE = /\.(mjs|cjs|js|ts)$/
13
+ const PY_RE = /\.py$/
14
+
15
+ function lineOf(src, idx) { return src.slice(0, idx).split('\n').length }
16
+
17
+ function shellRules(content) {
18
+ const out = []
19
+ content.split('\n').forEach((l, i) => {
20
+ const code = l.replace(/(^|\s)#.*$/, '')
21
+ if (/\|\|\s*(true|:|exit\s+0)\b/.test(code)) out.push({ line: i + 1, rule: 'OR_TRUE', severity: 'high', message: 'a failing command is forced to success (`|| true` / `|| exit 0`) — the step stays green when it broke. FIX: let it fail, or handle the specific expected error and fail on everything else.' })
22
+ if (/^\s*set\s+\+e\b/.test(code)) out.push({ line: i + 1, rule: 'SET_PLUS_E', severity: 'medium', message: '`set +e` turns off fail-on-error for everything after it. FIX: keep `set -e`; capture an expected non-zero exit explicitly (`cmd || rc=$?`) and act on rc.' })
23
+ if (/^\s*continue-on-error\s*:\s*true\b/.test(code)) out.push({ line: i + 1, rule: 'CONTINUE_ON_ERROR', severity: 'medium', message: '`continue-on-error: true` — a red step does not fail the job. FIX: remove it, or scope it to a step whose failure is recorded elsewhere.' })
24
+ if (/^\s*allow_failure\s*:\s*true\b/.test(code)) out.push({ line: i + 1, rule: 'ALLOW_FAILURE', severity: 'medium', message: '`allow_failure: true` — the pipeline passes when this job fails. FIX: remove it or document the owner and expiry.' })
25
+ })
26
+ return out
27
+ }
28
+
29
+ function packageJsonRules(content) {
30
+ const out = []
31
+ let pkg
32
+ try { pkg = JSON.parse(content) } catch { return out }
33
+ for (const [name, cmd] of Object.entries(pkg.scripts || {})) {
34
+ if (/\|\|\s*(true|:|exit\s+0)\b/.test(cmd) || /--passWithNoTests\b/.test(cmd)) {
35
+ const idx = content.indexOf(JSON.stringify(name))
36
+ out.push({ line: idx >= 0 ? lineOf(content, idx) : 1, rule: 'OR_TRUE', severity: 'high', message: `npm script "${name}" cannot fail (\`|| true\` / \`--passWithNoTests\`). FIX: let the command's exit code through.` })
37
+ }
38
+ }
39
+ return out
40
+ }
41
+
42
+ /** Find `catch (e) { … }` / `.catch(() => …)` bodies in masked JS and flag the fail-open shapes. */
43
+ function jsCatchRules(src) {
44
+ const out = []
45
+ const masked = maskStringsAndComments(src)
46
+ const re = /\bcatch\s*(\([^)]*\))?\s*\{/g
47
+ let m
48
+ while ((m = re.exec(masked)) !== null) {
49
+ const open = m.index + m[0].length - 1
50
+ let depth = 0, close = -1
51
+ for (let i = open; i < masked.length; i++) {
52
+ if (masked[i] === '{') depth++
53
+ else if (masked[i] === '}' && --depth === 0) { close = i; break }
54
+ }
55
+ if (close < 0) continue
56
+ const body = masked.slice(open + 1, close)
57
+ const line = lineOf(src, m.index)
58
+ if (/process\.exit\s*\(\s*0?\s*\)/.test(body)) out.push({ line, rule: 'CATCH_EXIT_ZERO', severity: 'high', message: 'the error handler exits 0 — the script reports success exactly when it failed. FIX: exit non-zero (or rethrow) in the catch.' })
59
+ else if (body.trim() === '') out.push({ line, rule: 'EMPTY_CATCH', severity: 'medium', message: 'an empty catch swallows the error — the script carries on as if it succeeded. FIX: rethrow, or log and exit non-zero.' })
60
+ }
61
+ const pc = /\.catch\s*\(\s*(?:\([^)]*\)|[A-Za-z_$][\w$]*)?\s*=>\s*(\{\s*\}|null|undefined|void 0|\(\s*\)|false|true)\s*\)/g
62
+ while ((m = pc.exec(masked)) !== null) out.push({ line: lineOf(src, m.index), rule: 'EMPTY_CATCH', severity: 'medium', message: '`.catch(() => {})` swallows the rejection — the script carries on as if it succeeded. FIX: let it reject, or log and exit non-zero.' })
63
+ return out
64
+ }
65
+
66
+ function jsGuardRules(src) {
67
+ const lines = src.split('\n')
68
+ return rawGuards(src)
69
+ // generalisation for customer repos: a guard realpath'd on both sides is correct, not fail-open
70
+ .filter((g) => !/realpath/i.test(lines.slice(Math.max(0, g.line - 3), g.line + 2).join('\n')))
71
+ .map((g) => ({ line: g.line, rule: 'RAW_ENTRYPOINT_GUARD', severity: 'medium', message: `entrypoint guard compares process.argv[1] with the module path without realpath on both sides (\`${g.text}\`). Run through a symlink (macOS /tmp, a symlinked checkout, npx bin links) main() never runs and the process exits 0 silently. FIX: compare realpathSync(process.argv[1]) with realpathSync(fileURLToPath(import.meta.url)).` }))
72
+ }
73
+
74
+ function pyRules(content) {
75
+ const out = []
76
+ const lines = content.split('\n')
77
+ lines.forEach((l, i) => {
78
+ if (!/^\s*except\b[^:]*:\s*(#.*)?$/.test(l)) return
79
+ const next = (lines.slice(i + 1).find((x) => x.trim() && !/^\s*#/.test(x)) || '').trim()
80
+ if (/^(pass|sys\.exit\(\s*0?\s*\)|exit\(\s*0?\s*\)|return\s+0)$/.test(next)) out.push({ line: i + 1, rule: 'EXCEPT_PASS', severity: next === 'pass' ? 'medium' : 'high', message: `the exception handler does \`${next}\` — the script reports success when it failed. FIX: re-raise, or exit non-zero.` })
81
+ })
82
+ return out
83
+ }
84
+
85
+ export default {
86
+ id: 'fail-open',
87
+ version: '0.1.0',
88
+ title: 'Fail-open CI steps and scripts',
89
+ provenance: 'finipe scripts/ci/check-entrypoint-guard.mjs rawGuards() @ 4ac53b367; CI/catch rules new',
90
+ appliesTo: (rel) => CI_FILE_RE.test(rel) || SHELL_RE.test(rel) || /(^|\/)package\.json$/.test(rel)
91
+ || ((JS_RE.test(rel) || PY_RE.test(rel)) && SCRIPT_DIR_RE.test(rel))
92
+ || /\.(mjs)$/.test(rel),
93
+ scanFile(rel, content) {
94
+ if (/(^|\/)package\.json$/.test(rel)) return packageJsonRules(content)
95
+ if (CI_FILE_RE.test(rel) || SHELL_RE.test(rel)) return shellRules(content)
96
+ if (PY_RE.test(rel)) return pyRules(content)
97
+ const out = jsGuardRules(content)
98
+ if (SCRIPT_DIR_RE.test(rel)) out.push(...jsCatchRules(content))
99
+ return out
100
+ },
101
+ rules: {
102
+ OR_TRUE: { severity: 'high', summary: 'a failing command is forced to success (`|| true`, `|| exit 0`, `--passWithNoTests`)', fix: 'let it fail, or handle the one expected error' },
103
+ SET_PLUS_E: { severity: 'medium', summary: '`set +e` turns off fail-on-error for the rest of the script', fix: 'keep set -e; capture an expected exit code explicitly' },
104
+ CONTINUE_ON_ERROR: { severity: 'medium', summary: '`continue-on-error: true` — a red step does not fail the job', fix: 'remove it' },
105
+ ALLOW_FAILURE: { severity: 'medium', summary: '`allow_failure: true` — the pipeline passes when this job fails', fix: 'remove it or document owner + expiry' },
106
+ CATCH_EXIT_ZERO: { severity: 'high', summary: 'the error handler exits 0 — success is reported exactly when it failed', fix: 'exit non-zero or rethrow' },
107
+ EMPTY_CATCH: { severity: 'medium', summary: 'an empty catch swallows the error', fix: 'rethrow, or log and exit non-zero' },
108
+ EXCEPT_PASS: { severity: 'medium', summary: 'a Python except does pass / exit(0)', fix: 're-raise or exit non-zero' },
109
+ RAW_ENTRYPOINT_GUARD: { severity: 'medium', summary: 'ESM entry-point guard without realpath — main() silently never runs through a symlink', fix: 'compare realpathSync on both sides' },
110
+ },
111
+ negativeControls: [
112
+ { id: 'fail-open/NC-1', file: '.github/workflows/ci.yml', expect: 'OR_TRUE', content: 'jobs:\n t:\n steps:\n - run: npm test || true\n' },
113
+ { id: 'fail-open/NC-2', file: 'scripts/deploy.mjs', expect: 'CATCH_EXIT_ZERO', content: "try {\n await deploy()\n} catch (e) {\n console.error(e)\n process.exit(0)\n}\n" },
114
+ { id: 'fail-open/NC-3', file: 'scripts/check.mjs', expect: 'RAW_ENTRYPOINT_GUARD', content: "import { fileURLToPath } from 'node:url'\nif (" + 'process.argv' + "[1] === fileURLToPath(import.meta.url)) main()\n" }, // concatenated: no raw guard in this file
115
+ { id: 'fail-open/NC-4', file: 'scripts/migrate.py', expect: 'EXCEPT_PASS', content: 'try:\n migrate()\nexcept Exception:\n sys.exit(0)\n' },
116
+ { id: 'fail-open/NC-5', file: 'package.json', expect: 'OR_TRUE', content: '{\n "scripts": {\n "test": "jest || true"\n }\n}\n' },
117
+ { id: 'fail-open/NC-6', file: 'scripts/release.sh', expect: 'SET_PLUS_E', content: '#!/bin/sh\nset +e\nnpm test\nnpm publish\n' },
118
+ { id: 'fail-open/NC-7', file: '.github/workflows/test.yml', expect: 'CONTINUE_ON_ERROR', content: 'jobs:\n t:\n steps:\n - run: npm test\n continue-on-error: true\n' },
119
+ { id: 'fail-open/NC-8', file: '.gitlab-ci.yml', expect: 'ALLOW_FAILURE', content: 'test:\n script: npm test\n allow_failure: true\n' },
120
+ { id: 'fail-open/NC-9', file: 'scripts/sync.mjs', expect: 'EMPTY_CATCH', content: 'try {\n await sync()\n} catch (e) {}\nconsole.log("synced")\n' },
121
+ ],
122
+ // Positive controls: `rule` names the rule each is the nearest clean look-alike for.
123
+ positiveControls: [
124
+ { id: 'fail-open/PC-1', rule: 'OR_TRUE', file: '.github/workflows/ci.yml', content: 'jobs:\n t:\n steps:\n - run: npm test\n - run: npm run lint # never add || true here\n' },
125
+ { id: 'fail-open/PC-2', rule: 'RAW_ENTRYPOINT_GUARD', file: 'scripts/check.mjs', content: "import { realpathSync } from 'node:fs'\nimport { fileURLToPath } from 'node:url'\nconst isMain = realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))\ntry { run() } catch (e) { console.error(e); process.exit(1) }\n" },
126
+ { id: 'fail-open/PC-3', rule: 'SET_PLUS_E', file: 'scripts/release.sh', content: '#!/bin/sh\nset -e\nrc=0\nnpm test || rc=$?\nif [ "$rc" -ne 0 ]; then echo "tests failed" >&2; exit "$rc"; fi\n' },
127
+ { id: 'fail-open/PC-4', rule: 'CONTINUE_ON_ERROR', file: '.github/workflows/test.yml', content: 'jobs:\n t:\n steps:\n - run: npm test\n continue-on-error: false\n' },
128
+ { id: 'fail-open/PC-5', rule: 'ALLOW_FAILURE', file: '.gitlab-ci.yml', content: 'test:\n script: npm test\n allow_failure: false\n' },
129
+ { id: 'fail-open/PC-6', rule: 'EMPTY_CATCH', file: 'scripts/sync.mjs', content: 'try {\n await sync()\n} catch (e) {\n console.error(e)\n throw e\n}\n' },
130
+ { id: 'fail-open/PC-7', rule: 'CATCH_EXIT_ZERO', file: 'scripts/deploy.mjs', content: "try {\n await deploy()\n} catch (e) {\n console.error(e)\n process.exit(1)\n}\n" },
131
+ { id: 'fail-open/PC-8', rule: 'EXCEPT_PASS', file: 'scripts/migrate.py', content: 'try:\n migrate()\nexcept Exception:\n log.exception("migrate failed")\n sys.exit(1)\n' },
132
+ ],
133
+ }
@@ -0,0 +1,143 @@
1
+ // fini-proof adapter: gitleaks — the first engine run through the ORCHESTRATION CONTRACT (SSOT PART 9, action 5).
2
+ //
3
+ // The contract is the ordinary engine module shape (id, version, appliesTo, rules, negativeControls,
4
+ // positiveControls) plus three optional members the runner understands:
5
+ // precondition() → { identity } | { notApplicable } | { unmeasured } checked BEFORE the controls run
6
+ // scanRepo(files, ctx) → findings for a set of { rel, content } at once (an external tool scans a directory,
7
+ // not one string); used by the runner instead of scanFile for the controls AND the scan
8
+ // replaces: '<id>' → when this engine is MEASURED in a run, the named built-in engine is NOT_APPLICABLE there
9
+ // (it is the fallback, SSOT PART 2 "secrets: MERGE under orchestration")
10
+ // Why new members instead of a separate adapter runner: the SSOT requires third-party engines to meet the SAME
11
+ // standard as ours (controls first, NOT_MEASURED on a miss), so they go through the same check() loop, the same
12
+ // evidence fields and the same readers. Nothing here forks the runner.
13
+ //
14
+ // What this adapter does:
15
+ // - uses a gitleaks binary that is ALREADY on PATH (or FINI_PROOF_GITLEAKS=<path>); it never downloads one.
16
+ // Absent → NOT_APPLICABLE with the reason, and the built-in `secrets` engine stays primary.
17
+ // FINI_PROOF_GITLEAKS=off → NOT_APPLICABLE (disabled). An explicit path that is not executable → NOT_MEASURED.
18
+ // - copies exactly the in-scope bytes the runner hashed into a temp directory and runs
19
+ // `gitleaks detect --no-git --source <copy> --report-format json --report-path <file> --redact --exit-code 0`,
20
+ // so the scanned bytes are the bytes in inputs.perFile (ignore globs, --base and --only apply unchanged).
21
+ // - plants its OWN negative controls (known-shaped secrets, one temp copy per control) before judging the repo;
22
+ // a missed control, a non-zero exit, a timeout or an unparseable report → NOT_MEASURED, never PASS.
23
+ // - never puts the secret (or gitleaks' redacted match) in a finding message.
24
+ import { spawnSync } from 'node:child_process'
25
+ import { createHash } from 'node:crypto'
26
+ import { accessSync, constants, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
27
+ import os from 'node:os'
28
+ import path from 'node:path'
29
+ import secrets from './secrets.mjs'
30
+
31
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000
32
+
33
+ function isExecutableFile(p) {
34
+ try { return statSync(p).isFile() && (accessSync(p, constants.X_OK), true) } catch { return false }
35
+ }
36
+
37
+ /** Where gitleaks is: { path, source } | { off } | { absent } | { error }. Pure over `env` (tests inject PATH). */
38
+ export function locateGitleaks(env = process.env) {
39
+ const o = env.FINI_PROOF_GITLEAKS
40
+ if (o === 'off') return { off: true }
41
+ if (o) return isExecutableFile(o) ? { path: o, source: 'FINI_PROOF_GITLEAKS' } : { error: `FINI_PROOF_GITLEAKS=${o} is not an executable file` }
42
+ for (const dir of (env.PATH || '').split(path.delimiter).filter(Boolean)) {
43
+ const p = path.join(dir, process.platform === 'win32' ? 'gitleaks.exe' : 'gitleaks')
44
+ if (isExecutableFile(p)) return { path: p, source: 'PATH' }
45
+ }
46
+ return { absent: true }
47
+ }
48
+
49
+ const timeoutMs = (env = process.env) => Number(env.FINI_PROOF_ADAPTER_TIMEOUT_MS) > 0 ? Number(env.FINI_PROOF_ADAPTER_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS
50
+ const tail = (s) => String(s || '').trim().split('\n').slice(-3).join(' | ').slice(0, 300)
51
+ export const ruleOf = (gitleaksRuleId) => `GITLEAKS_${String(gitleaksRuleId || 'UNKNOWN').toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`
52
+
53
+ function precondition(env = process.env) {
54
+ const loc = locateGitleaks(env)
55
+ if (loc.off) return { notApplicable: 'disabled by FINI_PROOF_GITLEAKS=off; the built-in secrets engine is primary' }
56
+ if (loc.absent) return { notApplicable: 'gitleaks is not on PATH, so the built-in secrets engine is primary (Fini Proof never downloads a scanner; install gitleaks to orchestrate it)' }
57
+ if (loc.error) return { unmeasured: loc.error }
58
+ const v = spawnSync(loc.path, ['version'], { encoding: 'utf8', timeout: 30_000 })
59
+ if (v.error || v.status !== 0) return { unmeasured: `\`gitleaks version\` failed (${v.error ? v.error.code || v.error.message : `exit ${v.status}`}: ${tail(v.stderr)}) — a scanner that cannot identify itself is not run` }
60
+ let binSha
61
+ try { binSha = createHash('sha256').update(readFileSync(loc.path)).digest('hex') } catch (e) { return { unmeasured: `cannot read the gitleaks binary to fingerprint it: ${e.code || e.message}` } }
62
+ return { identity: { tool: 'gitleaks', path: loc.path, source: loc.source, version: tail(v.stdout) || 'unknown', sha256: binSha } }
63
+ }
64
+
65
+ /** Run gitleaks over an in-memory file set. Throws on any failure (the runner turns a throw into NOT_MEASURED). */
66
+ function scanRepo(files, _ctx, env = process.env) {
67
+ const loc = locateGitleaks(env)
68
+ if (!loc.path) throw new Error(loc.error || 'gitleaks is not available')
69
+ const src = mkdtempSync(path.join(os.tmpdir(), 'fini-proof-gitleaks-src-'))
70
+ const out = mkdtempSync(path.join(os.tmpdir(), 'fini-proof-gitleaks-out-'))
71
+ try {
72
+ for (const { rel, content } of files) {
73
+ const dest = path.resolve(src, rel)
74
+ if (!dest.startsWith(src + path.sep)) throw new Error(`refusing to copy a path outside the scan copy: ${rel}`)
75
+ mkdirSync(path.dirname(dest), { recursive: true })
76
+ writeFileSync(dest, content)
77
+ }
78
+ const report = path.join(out, 'report.json')
79
+ const r = spawnSync(loc.path, ['detect', '--no-git', '--source', src, '--report-format', 'json', '--report-path', report, '--redact', '--exit-code', '0', '--no-banner'],
80
+ { encoding: 'utf8', timeout: timeoutMs(env), maxBuffer: 64 * 1024 * 1024 })
81
+ if (r.error) throw new Error(`gitleaks did not finish: ${r.error.code === 'ETIMEDOUT' ? `timed out after ${timeoutMs(env)} ms` : r.error.code || r.error.message}`)
82
+ if (r.status !== 0) throw new Error(`gitleaks exited ${r.status}${r.signal ? ` (${r.signal})` : ''}: ${tail(r.stderr)}`)
83
+ let items
84
+ try { items = JSON.parse(readFileSync(report, 'utf8')) } catch (e) { throw new Error(`gitleaks report is missing or not JSON (${e.code || e.message})`) }
85
+ if (!Array.isArray(items)) throw new Error('gitleaks report is not a JSON array')
86
+ return items.map((it) => {
87
+ const abs = path.resolve(src, String(it.File ?? ''))
88
+ const rel = path.relative(src, abs).split(path.sep).join('/')
89
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) throw new Error(`gitleaks reported a file outside the scan copy: ${it.File}`)
90
+ const line = Number.isInteger(it.StartLine) && it.StartLine > 0 ? it.StartLine : 1
91
+ const what = String(it.Description || it.RuleID || 'secret').replace(/[\x00-\x1f]/g, ' ').slice(0, 120)
92
+ return {
93
+ file: rel,
94
+ line,
95
+ rule: ruleOf(it.RuleID),
96
+ severity: 'high',
97
+ message: `${what} committed to the repository (gitleaks rule ${String(it.RuleID).slice(0, 80)}). Anyone with read access has it. FIX: rotate it now, then load it from the environment or a secret manager.`,
98
+ }
99
+ })
100
+ } finally {
101
+ rmSync(src, { recursive: true, force: true })
102
+ rmSync(out, { recursive: true, force: true })
103
+ }
104
+ }
105
+
106
+ // Planted material is assembled at runtime so this source file never contains a secret-shaped literal.
107
+ // The AWS control reuses the built-in engine's planted key; the others are gitleaks-specific shapes (gitleaks'
108
+ // github-pat rule has an entropy floor and its private-key rule needs a full BEGIN … END block).
109
+ const awsNc = secrets.negativeControls.find((c) => c.id === 'secrets/NC-1')
110
+ const pemBody = ['MIIEowIBAAKCAQEAu1SU1LfVLPHCozMxH2Mo4lgOEePzNm0tRgeLezV6ffAt0gun', 'VTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u+qKhbwKfBstIs+bMY2Zkp18gnTxK']
111
+ export const RULE_IDS = { aws: 'aws-access-token', github: 'github-pat', pem: 'private-key' }
112
+
113
+ export default {
114
+ id: 'gitleaks',
115
+ version: '0.1.0',
116
+ title: 'Committed secrets (gitleaks, orchestrated)',
117
+ provenance: 'adapter over a gitleaks binary already on PATH (github.com/gitleaks/gitleaks, MIT); controls planted by Fini Proof',
118
+ external: true,
119
+ replaces: 'secrets',
120
+ appliesTo: secrets.appliesTo,
121
+ precondition,
122
+ scanRepo,
123
+ scanFile() { throw new Error('gitleaks is a whole-directory adapter; the runner calls scanRepo') },
124
+ rules: {
125
+ [ruleOf(RULE_IDS.aws)]: { severity: 'high', summary: 'AWS access key committed to the repository (gitleaks)', fix: 'rotate it now, then load it from the environment or a secret manager' },
126
+ [ruleOf(RULE_IDS.github)]: { severity: 'high', summary: 'GitHub personal access token committed to the repository (gitleaks)', fix: 'revoke it now, then load it from the environment or a secret manager' },
127
+ [ruleOf(RULE_IDS.pem)]: { severity: 'high', summary: 'private key committed to the repository (gitleaks)', fix: 'rotate the key pair now; keep private keys in a secret manager' },
128
+ },
129
+ negativeControls: [
130
+ { id: 'gitleaks/NC-1', file: 'nc/config.js', expect: ruleOf(RULE_IDS.aws), content: awsNc.content },
131
+ { id: 'gitleaks/NC-2', file: 'nc/gh.py', expect: ruleOf(RULE_IDS.github), content: `TOKEN = "${'ghp_' + 'R8mQ2vX7kL4pZ9wT' + 'c3NbY6hJ1fD5gS0aE2uK'}"\n` },
132
+ { id: 'gitleaks/NC-3', file: 'nc/deploy.key', expect: ruleOf(RULE_IDS.pem), content: `-----BEGIN ${'RSA PRIVATE'} KEY-----\n${pemBody.join('\n')}\n-----END ${'RSA PRIVATE'} KEY-----\n` },
133
+ ],
134
+ // Reused from the built-in engine: two general clean inputs, and per rule the nearest clean look-alike
135
+ // (a bare key prefix + env read, an env-read token, a PUBLIC key block).
136
+ positiveControls: [
137
+ ...secrets.positiveControls.filter((c) => ['secrets/PC-1', 'secrets/PC-2'].includes(c.id)).map(({ rule, ...c }) => ({ ...c, id: c.id.replace('secrets/', 'gitleaks/') })),
138
+ ...[['secrets/PC-4', RULE_IDS.aws], ['secrets/PC-6', RULE_IDS.github], ['secrets/PC-5', RULE_IDS.pem]].map(([id, gl]) => {
139
+ const c = secrets.positiveControls.find((x) => x.id === id)
140
+ return { ...c, id: id.replace('secrets/', 'gitleaks/'), rule: ruleOf(gl) }
141
+ }),
142
+ ],
143
+ }