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/report.mjs ADDED
@@ -0,0 +1,73 @@
1
+ // Output formats: text (humans), json (the evidence record itself), sarif (GitHub code scanning, SARIF 2.1.0).
2
+ // Reads evidence@1 and evidence@2 (the @2 fields go through evidence.mjs so an @1 record renders unchanged).
3
+ import { ENGINES, TOOL_VERSION } from './runner.mjs'
4
+ import { propertyId, snapshotOf } from './evidence.mjs'
5
+
6
+ const useColor = () => process.stdout.isTTY && !process.env.NO_COLOR
7
+ const c = (code, s) => (useColor() ? `\x1b[${code}m${s}\x1b[0m` : s)
8
+ const VERDICT_STYLE = { PASS: '1;32', FAIL: '1;31', NOT_MEASURED: '1;33' }
9
+
10
+ export function toText(r, { evidencePath } = {}) {
11
+ const L = []
12
+ L.push(`fini-proof ${TOOL_VERSION} · run ${r.runId}`)
13
+ const snap = r.snapshot ? snapshotOf(r) : null
14
+ L.push(`commit ${r.repo.commit || (r.repo.git ? '(no commit yet)' : '(not a git repo)')}${r.repo.dirty ? ' (+ uncommitted changes)' : ''}${snap?.tree_hash ? ` · tree ${snap.tree_hash.slice(0, 12)}` : ''} · scope ${r.scope.mode || '-'}${r.scope.base ? ` vs ${r.scope.base}` : ''} · fail-on ${r.failOn}`)
15
+ const m = r.attestation?.maker
16
+ if (m && m.source !== 'git-author' && m.source !== 'none') L.push(`maker ${m.kind}:${m.id}${m.model ? ` (${m.model})` : ''} · verifier fini-proof ${r.attestation.verifier?.version || TOOL_VERSION}`)
17
+ L.push('')
18
+ for (const e of r.engines) {
19
+ const nc = e.controls.filter((x) => x.kind === 'negative')
20
+ const ctl = nc.length ? `${nc.filter((x) => x.ok).length}/${nc.length} negative controls caught` : 'controls not run'
21
+ const st = c(e.status === 'MEASURED' ? '32' : e.status === 'NOT_MEASURED' ? '33' : '90', e.status.padEnd(14))
22
+ L.push(` ${e.id.padEnd(13)} ${st} ${String(e.filesScanned).padStart(5)} files · ${ctl}${e.findings ? ` · ${e.findings} finding(s)` : ''}`)
23
+ if (e.reason) L.push(` ${e.reason}`)
24
+ }
25
+ if (r.findings.length) {
26
+ L.push('')
27
+ L.push('Findings')
28
+ for (const f of r.findings) {
29
+ const sev = f.severity.toUpperCase()
30
+ L.push(` ${c(f.blocking ? '31' : '90', sev.padEnd(6))} ${f.file}:${f.line} ${c('1', f.rule)} [${f.engine}]`)
31
+ L.push(` ${f.message}`)
32
+ L.push(` proof: ${f.proof}`)
33
+ L.push(` control: ${f.negativeControl} (npx fini-proof negctl ${f.negativeControl})`)
34
+ }
35
+ }
36
+ L.push('')
37
+ const n = (st) => r.engines.filter((e) => e.status === st).length
38
+ const notRun = [n('NO_SCOPE') && `${n('NO_SCOPE')} with no changed file in scope`, n('NOT_APPLICABLE') && `${n('NOT_APPLICABLE')} not applicable`].filter(Boolean)
39
+ L.push(`VERDICT: ${c(VERDICT_STYLE[r.verdict], r.verdict)}${r.summary ? ` — ${r.summary.blocking} blocking / ${r.summary.findings} finding(s), ${r.summary.enginesMeasured}/${r.engines.length} engines measured${notRun.length ? ` (${notRun.join(', ')})` : ''}` : ''}`)
40
+ if (r.verdict === 'NOT_MEASURED' && r.reason) L.push(` why: ${r.reason}`)
41
+ if (evidencePath) L.push(`evidence: ${evidencePath} (sha256 ${r.evidenceDigest.slice(0, 16)}…)`)
42
+ return L.join('\n')
43
+ }
44
+
45
+ const SARIF_LEVEL = { high: 'error', medium: 'warning', low: 'note', info: 'note' }
46
+
47
+ export function toSarif(r) {
48
+ const rules = new Map()
49
+ for (const f of r.findings) {
50
+ const id = `${f.engine}/${f.rule}`
51
+ if (!rules.has(id)) rules.set(id, { id, name: f.rule, shortDescription: { text: `${f.rule} (${f.engine})` }, fullDescription: { text: f.message }, defaultConfiguration: { level: SARIF_LEVEL[f.severity] }, properties: { negativeControl: f.negativeControl } })
52
+ }
53
+ return {
54
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
55
+ version: '2.1.0',
56
+ runs: [{
57
+ tool: { driver: { name: 'fini-proof', version: TOOL_VERSION, informationUri: 'https://finipe.com/fini-proof', rules: [...rules.values()] } },
58
+ automationDetails: { id: `fini-proof/${r.runId}` },
59
+ versionControlProvenance: r.repo.commit ? [{ repositoryUri: 'file:///', revisionId: r.repo.commit }] : undefined,
60
+ invocations: [{ executionSuccessful: r.verdict !== 'NOT_MEASURED', properties: { verdict: r.verdict, evidenceDigest: r.evidenceDigest, schema: r.schema, treeHash: snapshotOf(r).tree_hash, maker: r.attestation?.maker || null, engines: r.engines.map((e) => ({ id: e.id, version: e.version, sha256: e.sha256, status: e.status, reason: e.reason })) } }],
61
+ results: r.findings.map((f) => ({
62
+ ruleId: `${f.engine}/${f.rule}`,
63
+ level: SARIF_LEVEL[f.severity],
64
+ message: { text: `${f.message}\nProof: ${f.proof}\nNegative control: ${f.negativeControl}` },
65
+ locations: [{ physicalLocation: { artifactLocation: { uri: f.file }, region: { startLine: f.line } } }],
66
+ partialFingerprints: { finiProof: `${f.engine}:${f.rule}:${f.file}:${f.line}` },
67
+ properties: { propertyId: propertyId(f), proof: f.proof, negativeControl: f.negativeControl, blocking: f.blocking },
68
+ })),
69
+ }],
70
+ }
71
+ }
72
+
73
+ export const engineIds = () => ENGINES.map((e) => e.id)
package/src/runner.mjs ADDED
@@ -0,0 +1,306 @@
1
+ // The verdict engine. Order per engine: negative controls (a planted defect MUST be caught) and positive controls
2
+ // (a clean input MUST stay clean) run FIRST; an engine that fails either is NOT_MEASURED — a check that cannot
3
+ // fail is not evidence. Then the engine scans the in-scope files. Any unreadable in-scope file makes the engine
4
+ // NOT_MEASURED (never silently skipped — finipe check-entrypoint-guard.mjs, Codex §17.19b).
5
+ import { createHash, randomUUID } from 'node:crypto'
6
+ import { readFileSync, statSync } from 'node:fs'
7
+ import path from 'node:path'
8
+ import { performance } from 'node:perf_hooks'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { git, globToRegExp, listFiles, loadBaseline, loadConfig, repoInfo } from './files.mjs'
11
+ import { EVIDENCE_SCHEMA, computeDigest, makerFrom, verifierFrom } from './evidence.mjs'
12
+ import hollow from './engines/hollow-tests.mjs'
13
+ import gitleaks from './engines/gitleaks.mjs'
14
+ import secrets from './engines/secrets.mjs'
15
+ import failOpen from './engines/fail-open.mjs'
16
+ import skipRatchet from './engines/skip-ratchet.mjs'
17
+ import tenantFilter from './engines/tenant-filter.mjs'
18
+ import migrations from './engines/migrations.mjs'
19
+
20
+ export const TOOL_VERSION = '0.3.0'
21
+ // Order matters in one place: an orchestrated engine runs before the built-in engine it `replaces` (gitleaks → secrets).
22
+ export const ENGINES = [hollow, gitleaks, secrets, failOpen, skipRatchet, tenantFilter, migrations]
23
+ const ENGINE_FILES = {
24
+ 'hollow-tests': ['engines/hollow-tests.mjs', 'engines/vendor/vacuous-spec-core.mjs'],
25
+ gitleaks: ['engines/gitleaks.mjs', 'engines/secrets.mjs', 'engines/vendor/secrets-core.mjs'],
26
+ secrets: ['engines/secrets.mjs', 'engines/vendor/secrets-core.mjs'],
27
+ 'fail-open': ['engines/fail-open.mjs', 'engines/vendor/entrypoint-guard-core.mjs', 'engines/vendor/vacuous-spec-core.mjs'],
28
+ 'skip-ratchet': ['engines/skip-ratchet.mjs', 'engines/vendor/vacuous-spec-core.mjs'],
29
+ 'tenant-filter': ['engines/tenant-filter.mjs', 'engines/migrations.mjs', 'engines/vendor/migration-invariants-core.mjs', 'engines/vendor/vacuous-spec-core.mjs'],
30
+ migrations: ['engines/migrations.mjs', 'engines/vendor/migration-invariants-core.mjs'],
31
+ }
32
+ const SRC_DIR = path.dirname(fileURLToPath(import.meta.url))
33
+ const MAX_BYTES = 5 * 1024 * 1024
34
+ export const SEVERITY_RANK = { info: 0, low: 1, medium: 2, high: 3 }
35
+
36
+ export const sha256 = (s) => createHash('sha256').update(s).digest('hex')
37
+
38
+ export function engineDigest(id) {
39
+ const h = createHash('sha256')
40
+ for (const f of ENGINE_FILES[id] || []) h.update(readFileSync(path.join(SRC_DIR, f)))
41
+ return h.digest('hex')
42
+ }
43
+
44
+ /**
45
+ * Run one engine over an in-memory file set; applies finalize (ratchet) with the given baseline. An orchestrated
46
+ * engine (engines/gitleaks.mjs, SSOT PART 9) scans the whole set at once via scanRepo and returns findings with `file`.
47
+ */
48
+ export function scanWith(engine, files, ctx) {
49
+ let findings = []
50
+ if (engine.scanRepo) {
51
+ for (const f of engine.scanRepo(files, ctx)) findings.push({ ...f, engine: engine.id })
52
+ } else {
53
+ for (const { rel, content } of files) {
54
+ for (const f of engine.scanFile(rel, content, ctx)) findings.push({ ...f, file: rel, engine: engine.id })
55
+ }
56
+ }
57
+ let measurements = {}
58
+ if (engine.finalize) ({ findings, measurements } = engine.finalize(findings, ctx))
59
+ return { findings, measurements }
60
+ }
61
+
62
+ export function runControl(engine, control, kind, root) {
63
+ const ctx = { root, baseline: {}, ...(control.ctx || {}) }
64
+ const t0 = performance.now()
65
+ let findings = null
66
+ let error = null
67
+ // A control that throws (an external engine crashed, timed out) is a control that did not pass — never skipped.
68
+ try { ({ findings } = scanWith(engine, [{ rel: control.file, content: control.content }], ctx)) } catch (e) { error = e.message }
69
+ const ms = Math.round((performance.now() - t0) * 1000) / 1000
70
+ const got = findings ? findings.map((f) => f.rule) : []
71
+ const errorField = error === null ? {} : { error }
72
+ if (kind === 'negative') {
73
+ const caught = !!findings && findings.some((f) => f.rule === control.expect)
74
+ return { id: control.id, kind, expect: control.expect, ok: caught, got, ms, ...errorField }
75
+ }
76
+ return { id: control.id, kind, ...(control.rule ? { rule: control.rule } : {}), ok: !!findings && findings.length === 0, got, ms, ...errorField }
77
+ }
78
+
79
+ /** evidence@2 `nc_results[]` of an engine record, from the control runs that gated it (no second run). */
80
+ export const ncResultsOf = (controls) => controls.filter((c) => c.kind === 'negative').map((c) => ({ id: c.id, expect: c.expect, caught: c.ok, ms: c.ms }))
81
+
82
+ /** The same ignore rule for check() and for `state` (what a proof's scope may contain). */
83
+ export function scopeFilter(cfg) {
84
+ const ignoreRes = [/^\.fini-proof\//, ...cfg.ignore.map(globToRegExp)]
85
+ return (f) => !ignoreRes.some((re) => re.test(f))
86
+ }
87
+
88
+ /** evidence@2 snapshot: the git HEAD tree. Not a git repo / no commit → NOT_MEASURED (the per-file hashes remain). */
89
+ export function snapshotOf(root, info) {
90
+ if (!info.git) return { tree_hash: null, commit: null, dirty: null, status: 'NOT_MEASURED', reason: 'not a git repository: no tree hash (inputs.perFile still identifies every file that was checked)' }
91
+ const t = git(root, ['rev-parse', '--verify', '--quiet', 'HEAD^{tree}'])
92
+ if (!t.ok || !t.out) return { tree_hash: null, commit: null, dirty: info.dirty, status: 'NOT_MEASURED', reason: 'the repository has no commit yet: no tree hash' }
93
+ return { tree_hash: t.out, commit: info.commit, dirty: info.dirty, status: 'MEASURED', ...(info.dirty ? { reason: 'uncommitted changes: tree_hash is the HEAD tree; inputs.perFile holds the bytes that were checked' } : {}) }
94
+ }
95
+
96
+ export function runControls(engine, root = process.cwd()) {
97
+ return [
98
+ ...engine.negativeControls.map((c) => runControl(engine, c, 'negative', root)),
99
+ ...(engine.positiveControls || []).map((c) => runControl(engine, c, 'positive', root)),
100
+ ]
101
+ }
102
+
103
+ /** A cached, fail-closed reader over the repository (an error is recorded, never swallowed). */
104
+ export function makeReader(root) {
105
+ const cache = new Map()
106
+ const read = (rel) => {
107
+ if (!cache.has(rel)) {
108
+ try {
109
+ const st = statSync(path.join(root, rel))
110
+ if (st.size > MAX_BYTES) cache.set(rel, { error: `larger than ${MAX_BYTES} bytes` })
111
+ else cache.set(rel, { content: readFileSync(path.join(root, rel), 'utf8') })
112
+ } catch (e) { cache.set(rel, { error: e.code || e.message }) }
113
+ }
114
+ return cache.get(rel)
115
+ }
116
+ return { read, cache }
117
+ }
118
+
119
+ /**
120
+ * Whole-repository context an engine needs before it scans (e.g. which tables are multi-tenant). Runs over the FULL
121
+ * listing even in --base mode: a changed query is judged against the whole schema, not just the changed files.
122
+ * Returns { ctx, measurements } or { unmeasured } / { notApplicable }.
123
+ */
124
+ export function prepareEngine(engine, { root, cfg, allFiles, read }) {
125
+ // (read may be an engine-scoped wrapper that records which files this engine looked at — evidence@2 scope_files)
126
+ const base = { exists: (rel) => allFiles.has(rel), read: (rel) => read(rel).content }
127
+ if (!engine.prepare) return { ctx: base }
128
+ const p = engine.prepare({ root, config: cfg.raw || {}, files: [...allFiles], read })
129
+ return { ...p, ctx: { ...base, ...(p.ctx || {}) } }
130
+ }
131
+
132
+ function proofCommand(f) {
133
+ return `npx fini-proof prove --engine ${f.engine} --file ${JSON.stringify(f.file)} --line ${f.line}`
134
+ }
135
+
136
+ export function check(root, opts = {}) {
137
+ const startedAt = new Date().toISOString()
138
+ const failOnName = opts.failOn || 'high'
139
+ const failOn = failOnName === 'none' ? Infinity : SEVERITY_RANK[failOnName]
140
+ if (failOn === undefined) throw new Error(`--fail-on must be one of high|medium|low|none (got ${failOnName})`)
141
+ const cfg = loadConfig(root)
142
+ const baseline = loadBaseline(root)
143
+ const info = repoInfo(root)
144
+ let listed = listFiles(root, { base: opts.base })
145
+ if (opts.only && opts.only.length && !listed.unmeasured) {
146
+ // --only: exactly these files (an editor / agent hook checking the file it just wrote). Missing = not in repo.
147
+ const want = new Set(opts.only.map((f) => path.relative(root, path.resolve(root, f)).split(path.sep).join('/')))
148
+ listed = { ...listed, files: listed.files.filter((f) => want.has(f)), mode: 'files', only: [...want] }
149
+ }
150
+ const selected = (opts.engines && opts.engines.length) ? ENGINES.filter((e) => opts.engines.includes(e.id)) : ENGINES
151
+ const result = {
152
+ schema: EVIDENCE_SCHEMA,
153
+ runId: randomUUID(),
154
+ tool: { name: 'fini-proof', version: TOOL_VERSION, node: process.version },
155
+ startedAt,
156
+ repo: { ...info, rootCommitDigest: info.rootCommit ? sha256(info.rootCommit) : sha256(path.resolve(root)) },
157
+ scope: { mode: listed.mode || null, ...(listed.only ? { only: listed.only } : {}), base: opts.base || null, baseCommit: listed.baseCommit || null, mergeBase: listed.mergeBase || null, ignore: cfg.ignore, config: cfg.source },
158
+ snapshot: snapshotOf(root, info),
159
+ failOn: failOnName,
160
+ engines: [],
161
+ findings: [],
162
+ inputs: { files: 0, digest: null, perFile: {} },
163
+ }
164
+ const attest = () => { result.attestation = { maker: makerFrom({ explicit: opts.maker, gitAuthor: info.author }), verifier: verifierFrom({ version: TOOL_VERSION, engines: result.engines }) } }
165
+ if (listed.unmeasured) {
166
+ result.verdict = 'NOT_MEASURED'
167
+ result.reason = listed.unmeasured
168
+ attest()
169
+ return finish(result)
170
+ }
171
+ const keep = scopeFilter(cfg)
172
+ const files = listed.files.filter(keep)
173
+ const { read, cache } = makeReader(root)
174
+ let allFiles = null
175
+ const all = () => {
176
+ if (!allFiles) allFiles = new Set(listed.mode === 'full' || listed.mode === 'full-walk' ? files : (listFiles(root).files || []).filter(keep))
177
+ return allFiles
178
+ }
179
+ for (const engine of selected) {
180
+ const er = { id: engine.id, version: engine.version, title: engine.title, provenance: engine.provenance, sha256: engineDigest(engine.id), controls: [], nc_results: [], scope_files: [], status: 'MEASURED', filesScanned: 0 }
181
+ result.engines.push(er)
182
+ // Orchestration contract (engines/gitleaks.mjs): a built-in engine yields to the orchestrated engine that replaces
183
+ // it when that engine was MEASURED in this run; an external engine that is absent is NOT_APPLICABLE before its
184
+ // controls, and its binary fingerprint is part of the engine digest (a new binary = a new verifier).
185
+ const primary = result.engines.find((x) => x.status === 'MEASURED' && selected.some((e) => e.id === x.id && e.replaces === engine.id))
186
+ if (primary) { er.status = 'NOT_APPLICABLE'; er.reason = `${primary.id} passed its negative controls and is the primary check this run; ${engine.id} is the fallback (SSOT PART 9)`; continue }
187
+ if (engine.precondition) {
188
+ const pc = engine.precondition()
189
+ if (pc.notApplicable) { er.status = 'NOT_APPLICABLE'; er.reason = pc.notApplicable; continue }
190
+ if (pc.unmeasured) { er.status = 'NOT_MEASURED'; er.reason = pc.unmeasured; continue }
191
+ if (pc.identity) { er.external = pc.identity; er.sha256 = sha256(`${er.sha256}\n${pc.identity.sha256}`) }
192
+ }
193
+ const controls = runControls(engine, root)
194
+ er.controls = controls
195
+ er.nc_results = ncResultsOf(controls)
196
+ const badControl = controls.find((c) => !c.ok)
197
+ if (badControl) {
198
+ er.status = 'NOT_MEASURED'
199
+ er.reason = badControl.error
200
+ ? `${badControl.kind} control ${badControl.id} could not run (${badControl.error}) — this check cannot be shown to fail, so its silence is not evidence`
201
+ : badControl.kind === 'negative'
202
+ ? `negative control ${badControl.id} was NOT caught (planted ${badControl.expect}, got [${badControl.got}]) — this check cannot fail, so its silence is not evidence`
203
+ : `positive control ${badControl.id} raised [${badControl.got}] on a clean input — this check cannot be trusted`
204
+ continue
205
+ }
206
+ const touched = new Set()
207
+ const readE = (rel) => { touched.add(rel); return read(rel) }
208
+ const scopeOut = () => { er.scope_files = [...touched].filter((rel) => cache.get(rel)?.content !== undefined).sort() }
209
+ const prep = prepareEngine(engine, { root, cfg, allFiles: all(), read: readE })
210
+ if (prep.measurements) er.measurements = prep.measurements
211
+ if (prep.unmeasured) { er.status = 'NOT_MEASURED'; er.reason = prep.unmeasured; continue }
212
+ if (prep.notApplicable) { er.status = 'NOT_APPLICABLE'; er.reason = prep.notApplicable; continue }
213
+ const inScope = files.filter((f) => engine.appliesTo(f))
214
+ const unreadable = []
215
+ const loaded = []
216
+ for (const rel of inScope) {
217
+ const r = readE(rel)
218
+ if (r.error) unreadable.push(`${rel} (${r.error})`)
219
+ else loaded.push({ rel, content: r.content })
220
+ }
221
+ er.filesScanned = loaded.length
222
+ scopeOut()
223
+ if (unreadable.length) {
224
+ er.status = 'NOT_MEASURED'
225
+ er.reason = `${unreadable.length} in-scope file(s) could not be read: ${unreadable.slice(0, 5).join(', ')} — an unread file is not a clean file`
226
+ continue
227
+ }
228
+ if (loaded.length === 0) {
229
+ if (listed.mode === 'diff' || listed.mode === 'files') { er.status = 'NO_SCOPE'; er.reason = 'no changed file is in this engine\'s scope' }
230
+ else if (engine.optionalScope) { er.status = 'NOT_APPLICABLE'; er.reason = engine.notApplicableReason }
231
+ else { er.status = 'NOT_MEASURED'; er.reason = 'no file in this repository is in this engine\'s scope — an empty scan is not a pass' }
232
+ continue
233
+ }
234
+ try {
235
+ const { findings, measurements } = scanWith(engine, loaded, { root, baseline, ...prep.ctx })
236
+ er.measurements = { ...(er.measurements || {}), ...measurements }
237
+ const nc = engine.negativeControls
238
+ for (const f of findings) {
239
+ const ctl = nc.find((c) => c.expect === f.rule)
240
+ result.findings.push({
241
+ ...f,
242
+ property_id: `${f.engine}/${f.rule}`,
243
+ negativeControl: ctl ? ctl.id : nc[0].id,
244
+ proof: proofCommand(f),
245
+ blocking: SEVERITY_RANK[f.severity] >= failOn,
246
+ })
247
+ }
248
+ er.findings = findings.length
249
+ } catch (e) {
250
+ er.status = 'NOT_MEASURED'
251
+ er.reason = `engine crashed: ${e.message}`
252
+ }
253
+ }
254
+ for (const [rel, r] of cache) if (r.content !== undefined) result.inputs.perFile[rel] = sha256(r.content)
255
+ result.inputs.files = Object.keys(result.inputs.perFile).length
256
+ result.inputs.digest = sha256(Object.entries(result.inputs.perFile).sort().map(([k, v]) => `${v} ${k}`).join('\n'))
257
+ const blocking = result.findings.filter((f) => f.blocking).length
258
+ const unmeasured = result.engines.filter((e) => e.status === 'NOT_MEASURED')
259
+ result.summary = { findings: result.findings.length, blocking, enginesMeasured: result.engines.filter((e) => e.status === 'MEASURED').length, enginesNotMeasured: unmeasured.length }
260
+ if (blocking > 0) result.verdict = 'FAIL'
261
+ else if (unmeasured.length > 0) { result.verdict = 'NOT_MEASURED'; result.reason = unmeasured.map((e) => `${e.id}: ${e.reason}`).join('; ') }
262
+ else result.verdict = 'PASS'
263
+ attest()
264
+ return finish(result)
265
+ }
266
+
267
+ function finish(result) {
268
+ result.finishedAt = new Date().toISOString()
269
+ delete result.evidenceDigest
270
+ result.evidenceDigest = computeDigest(result)
271
+ return result
272
+ }
273
+
274
+ export const EXIT = { PASS: 0, FAIL: 1, USAGE: 2, NOT_MEASURED: 3, LICENCE: 4 }
275
+
276
+ /**
277
+ * Re-run one engine on one file (the `prove` command, the MCP explain_finding tool). Controls first: an engine whose
278
+ * control fails proves nothing. Returns { status: 'NOT_MEASURED', reason } | { status: 'OK', hits, content, sha }.
279
+ */
280
+ export function prove(root, engineId, file, line = null) {
281
+ const engine = ENGINES.find((e) => e.id === engineId)
282
+ if (!engine) return { status: 'USAGE', reason: `unknown engine ${engineId}` }
283
+ const pc = engine.precondition ? engine.precondition() : {}
284
+ if (pc.notApplicable || pc.unmeasured) return { status: 'NOT_MEASURED', reason: `${engine.id}: ${pc.notApplicable || pc.unmeasured}` }
285
+ const bad = runControls(engine, root).find((c) => !c.ok)
286
+ if (bad) return { status: 'NOT_MEASURED', reason: `${engine.id} control ${bad.id} failed; this check cannot prove anything` }
287
+ const { read } = makeReader(root)
288
+ const r = read(file)
289
+ if (r.error) return { status: 'NOT_MEASURED', reason: `cannot read ${file}: ${r.error}` }
290
+ const listed = listFiles(root)
291
+ const prep = prepareEngine(engine, { root, cfg: loadConfig(root), allFiles: new Set(listed.files || []), read })
292
+ if (prep.unmeasured || prep.notApplicable) return { status: 'NOT_MEASURED', reason: prep.unmeasured || prep.notApplicable }
293
+ let findings
294
+ try { ({ findings } = scanWith(engine, [{ rel: file, content: r.content }], { root, baseline: loadBaseline(root), ...prep.ctx })) } catch (e) { return { status: 'NOT_MEASURED', reason: `${engine.id} crashed: ${e.message}` } }
295
+ const hits = findings.filter((f) => line === null || f.line === line)
296
+ return { status: 'OK', engine, hits, content: r.content, sha: sha256(r.content) }
297
+ }
298
+
299
+ /** The rule catalogue (list_rules / explain_finding): every engine, its rules, provenance and controls. */
300
+ export function ruleCatalogue() {
301
+ return ENGINES.map((e) => ({
302
+ engine: e.id, version: e.version, title: e.title, provenance: e.provenance,
303
+ rules: Object.entries(e.rules || {}).map(([id, r]) => ({ id, ...r, negativeControl: (e.negativeControls.find((c) => c.expect === id) || {}).id || null })),
304
+ negativeControls: e.negativeControls.map((c) => c.id),
305
+ }))
306
+ }
package/src/state.mjs ADDED
@@ -0,0 +1,189 @@
1
+ // `fini-proof state` — Proof State v0, local form (SSOT PART 5 and PART 13 NOW-3).
2
+ //
3
+ // Five states per property (property = "<engine>/<rule>"), no sixth:
4
+ // PROVEN the engine passed ALL its controls in that run, the rule has its OWN negative control that was
5
+ // caught and its OWN positive control (nearest clean look-alike, `rule` on the control) that stayed
6
+ // clean in that run, and no finding of the rule exists in the engine's scope
7
+ // FAILED a finding of severity medium/high exists (each carries a proof command); `blocking` says whether
8
+ // it failed the run under --fail-on
9
+ // NOT_MEASURED the engine was not MEASURED (a control failed, unreadable file, not applicable, no scope …), or
10
+ // the rule lacks its own negative or positive control. `cause` says which; a NOT_APPLICABLE engine
11
+ // stays NOT_MEASURED here (SSOT PART 5: five states, no sixth) with cause 'engine-not-applicable'
12
+ // STALE derived at READ time only (`--against`): the record said PROVEN/FAILED, but a file in the proof's
13
+ // scope changed (per-file sha256 differs, deleted, or a new in-scope file appeared in a full-scope
14
+ // proof) or the engine digest changed since. The fix is the next run.
15
+ // ADVISORY the rule's severity is low/info (e.g. TENANT_FILTER_UNPROVEN, SKIPPED_TEST): a recommendation,
16
+ // never rendered as PROVEN, never blocking
17
+ //
18
+ // Two modes:
19
+ // fini-proof state run the licensed check now (same path as `check`) and report its states
20
+ // fini-proof state --against <file> run NOTHING: verify that evidence file's digest, derive its states, and
21
+ // mark STALE whatever the working tree has invalidated since
22
+ // Reuse: states are derived from the evidence record itself (evidence.mjs readers accept @1 and @2), file hashing
23
+ // uses the runner's fail-closed reader, the scope uses the runner's ignore filter and the engines' own appliesTo.
24
+ import path from 'node:path'
25
+ import { ncResults, propertyId, scopeFiles, snapshotOf } from './evidence.mjs'
26
+ import { listFiles, loadConfig } from './files.mjs'
27
+ import { ENGINES, engineDigest, makeReader, scopeFilter, sha256 } from './runner.mjs'
28
+
29
+ export const STATES = Object.freeze(['PROVEN', 'FAILED', 'NOT_MEASURED', 'STALE', 'ADVISORY'])
30
+ export const STATE_SCHEMA = 'fini-proof/state@1'
31
+ const RANK = { info: 0, low: 1, medium: 2, high: 3 }
32
+ const FULL_MODES = new Set(['full', 'full-walk'])
33
+
34
+ /** Per-property states of one evidence record (no file-system access). */
35
+ export function deriveStates(doc, engines = ENGINES) {
36
+ const rows = []
37
+ const snap = snapshotOf(doc)
38
+ const findingsBy = new Map()
39
+ for (const f of doc.findings || []) {
40
+ const id = propertyId(f)
41
+ if (!findingsBy.has(id)) findingsBy.set(id, [])
42
+ findingsBy.get(id).push(f)
43
+ }
44
+ for (const er of doc.engines || []) {
45
+ const mod = engines.find((e) => e.id === er.id)
46
+ const catalogue = mod?.rules || {}
47
+ const ruleIds = new Set([...Object.keys(catalogue), ...(doc.findings || []).filter((f) => f.engine === er.id).map((f) => f.rule)])
48
+ const ncs = ncResults(er)
49
+ const controls = er.controls || []
50
+ // Trust only what the record shows: MEASURED *and* every control it ran passed (a status alone is not enough).
51
+ const engineOk = er.status === 'MEASURED' && controls.length > 0 && controls.every((c) => c.ok === true) && ncs.every((n) => n.caught === true)
52
+ const { files: scope, source: scopeSource } = scopeFiles(er, doc)
53
+ for (const rule of [...ruleIds].sort()) {
54
+ const pid = `${er.id}/${rule}`
55
+ const fs = findingsBy.get(pid) || []
56
+ const nc = ncs.find((n) => n.expect === rule) || null
57
+ const pc = controls.find((c) => c.kind === 'positive' && c.rule === rule) || null
58
+ const sev = catalogue[rule]?.severity || fs.reduce((m, f) => (RANK[f.severity] > RANK[m] ? f.severity : m), 'info')
59
+ const row = {
60
+ property: pid, engine: er.id, rule, severity: sev, state: null, reason: null,
61
+ runId: doc.runId, snapshot: snap.tree_hash ?? null, commit: snap.commit ?? null,
62
+ engineVersion: er.version ?? null, engineDigest: er.sha256 ?? null, engineStatus: er.status,
63
+ ncId: nc ? nc.id : null, pcId: pc ? pc.id : null, cause: null, findings: fs.length, blocking: fs.filter((f) => f.blocking).length,
64
+ provenAt: null, scope, scopeSource,
65
+ }
66
+ const serious = fs.filter((f) => RANK[f.severity] >= RANK.medium)
67
+ if (!engineOk) {
68
+ row.state = 'NOT_MEASURED'
69
+ row.cause = er.status === 'MEASURED' ? 'control-failed' : er.status === 'NOT_APPLICABLE' ? 'engine-not-applicable' : er.status === 'NO_SCOPE' ? 'no-scope' : 'engine-not-measured'
70
+ row.reason = er.status === 'MEASURED' ? 'the record shows a control that did not pass — nothing from this engine is proof' : `${er.status}${er.reason ? `: ${er.reason}` : ''}`
71
+ } else if (serious.length) {
72
+ row.state = 'FAILED'
73
+ row.reason = `${serious.length} finding(s); re-run: ${serious[0].proof}`
74
+ } else if (RANK[sev] <= RANK.low || fs.length) {
75
+ row.state = 'ADVISORY'
76
+ row.reason = fs.length ? `${fs.length} low/info finding(s) — a recommendation, not a proof` : 'low/info rule — advisory only, never shown as proven'
77
+ } else if (!nc) {
78
+ row.state = 'NOT_MEASURED'
79
+ row.cause = 'missing-negative-control'
80
+ row.reason = 'no negative control plants this rule, so it has not been shown able to fail'
81
+ } else if (!pc) {
82
+ row.state = 'NOT_MEASURED'
83
+ row.cause = 'missing-positive-control'
84
+ row.reason = 'no positive control is this rule\'s clean look-alike, so a detector too lax for it would go unseen'
85
+ } else if (nc.caught !== true || pc.ok !== true) {
86
+ row.state = 'NOT_MEASURED'
87
+ row.cause = 'control-failed'
88
+ row.reason = nc.caught !== true ? `negative control ${nc.id} was not caught` : `positive control ${pc.id} raised a finding`
89
+ } else {
90
+ row.state = 'PROVEN'
91
+ row.provenAt = doc.finishedAt || null
92
+ }
93
+ rows.push(row)
94
+ }
95
+ }
96
+ return rows
97
+ }
98
+
99
+ /**
100
+ * Read-time invalidation: PROVEN/FAILED rows whose scope changed on disk (or whose engine changed) become STALE.
101
+ * Unchanged rows keep their state. Returns the same rows (mutated) for convenience.
102
+ */
103
+ export function applyStaleness(rows, doc, root, engines = ENGINES) {
104
+ const { read } = makeReader(root)
105
+ const recorded = doc.inputs?.perFile || {}
106
+ const now = new Map()
107
+ const hashNow = (rel) => {
108
+ if (!now.has(rel)) { const r = read(rel); now.set(rel, r.error ? null : sha256(r.content)) }
109
+ return now.get(rel)
110
+ }
111
+ // Files in the repository now, under the same ignore rule — to see NEW in-scope files for a full-scope proof.
112
+ let current = null
113
+ const currentFiles = () => {
114
+ if (current === null) {
115
+ const listed = listFiles(root)
116
+ current = listed.unmeasured ? [] : listed.files.filter(scopeFilter(loadConfig(root)))
117
+ }
118
+ return current
119
+ }
120
+ const fullScope = FULL_MODES.has(doc.scope?.mode)
121
+ for (const row of rows) {
122
+ if (row.state !== 'PROVEN' && row.state !== 'FAILED') continue
123
+ const changed = []
124
+ for (const rel of row.scope) {
125
+ const h = hashNow(rel)
126
+ if (h === null) changed.push({ file: rel, change: 'deleted or unreadable' })
127
+ else if (h !== recorded[rel]) changed.push({ file: rel, change: 'modified' })
128
+ }
129
+ const mod = engines.find((e) => e.id === row.engine)
130
+ if (fullScope && mod && row.scopeSource === 'engine') {
131
+ const inScope = new Set(row.scope)
132
+ for (const rel of currentFiles()) if (mod.appliesTo(rel) && !inScope.has(rel)) changed.push({ file: rel, change: 'added' })
133
+ }
134
+ let engineChanged = null
135
+ if (mod && !mod.external && row.engineDigest && engineDigest(mod.id) !== row.engineDigest) engineChanged = `engine ${mod.id} changed since the proof (digest ${row.engineDigest.slice(0, 12)}… → ${engineDigest(mod.id).slice(0, 12)}…)`
136
+ if (!changed.length && !engineChanged) continue
137
+ row.previousState = row.state
138
+ row.state = 'STALE'
139
+ row.invalidatedBy = { files: changed.slice(0, 50), filesChanged: changed.length, ...(engineChanged ? { engine: engineChanged } : {}) }
140
+ row.reason = [changed.length ? `${changed.length} covered file(s) changed: ${changed.slice(0, 3).map((c) => `${c.file} (${c.change})`).join(', ')}${changed.length > 3 ? ', …' : ''}` : null, engineChanged].filter(Boolean).join('; ')
141
+ }
142
+ return rows
143
+ }
144
+
145
+ export function summarise(rows) {
146
+ const out = Object.fromEntries(STATES.map((s) => [s, rows.filter((r) => r.state === s).length]))
147
+ const causes = {}
148
+ for (const r of rows) if (r.state === 'NOT_MEASURED') causes[r.cause] = (causes[r.cause] || 0) + 1
149
+ out.notMeasuredBy = causes
150
+ return out
151
+ }
152
+
153
+ /** The JSON document `state --format json` prints. `scope` lists are replaced by counts to keep it readable. */
154
+ export function stateDocument(doc, rows, { mode, evidencePath, root }) {
155
+ return {
156
+ schema: STATE_SCHEMA,
157
+ mode,
158
+ generatedAt: new Date().toISOString(),
159
+ evidence: { path: evidencePath ? path.relative(root, evidencePath) : null, schema: doc.schema, runId: doc.runId, evidenceDigest: doc.evidenceDigest, finishedAt: doc.finishedAt || null, snapshot: snapshotOf(doc) },
160
+ summary: summarise(rows),
161
+ properties: rows.map(({ scope, ...r }) => ({ ...r, scopeFiles: scope.length })),
162
+ }
163
+ }
164
+
165
+ const ORDER = ['FAILED', 'STALE', 'NOT_MEASURED', 'ADVISORY', 'PROVEN']
166
+ export function stateText(sd) {
167
+ const L = []
168
+ const snap = sd.evidence.snapshot
169
+ L.push(`fini-proof state · ${sd.mode === 'against' ? 'as of now, against' : 'this run'} ${sd.evidence.runId} · tree ${snap.tree_hash ? snap.tree_hash.slice(0, 12) : `(none: ${snap.reason})`} · ${sd.evidence.schema}`)
170
+ L.push('')
171
+ for (const st of ORDER) {
172
+ const rows = sd.properties.filter((x) => x.state === st)
173
+ // An engine that did not run leaves every one of its properties NOT_MEASURED for the same reason: one line each.
174
+ const whole = st === 'NOT_MEASURED' ? [...new Set(rows.filter((x) => x.engineStatus !== 'MEASURED').map((x) => x.engine))] : []
175
+ for (const eng of whole) {
176
+ const g = rows.filter((x) => x.engine === eng)
177
+ L.push(` ${st.padEnd(13)} ${`${eng}/* (${g.length} properties)`.padEnd(40)} ${g[0].reason}`)
178
+ }
179
+ for (const p of rows.filter((x) => !whole.includes(x.engine))) {
180
+ const extra = p.state === 'PROVEN' ? `controls ${p.ncId} + ${p.pcId} · ${p.scopeFiles} file(s)` : p.reason
181
+ L.push(` ${p.state.padEnd(13)} ${p.property.padEnd(40)} ${p.previousState ? `(was ${p.previousState}) ` : ''}${extra || ''}`)
182
+ }
183
+ }
184
+ L.push('')
185
+ const nmBy = Object.entries(sd.summary.notMeasuredBy || {}).map(([k, v]) => `${v} ${k}`).join(', ')
186
+ L.push(`STATE: ${ORDER.map((s) => `${s} ${sd.summary[s]}${s === 'NOT_MEASURED' && nmBy ? ` (${nmBy})` : ''}`).join(' · ')}`)
187
+ L.push('ADVISORY is a recommendation, never a proof. STALE means re-run `fini-proof check`. Only PROVEN is proven.')
188
+ return L.join('\n')
189
+ }
package/src/upload.mjs ADDED
@@ -0,0 +1,85 @@
1
+ // `fini-proof check --upload`: send the run's evidence to the Finipe Developer workspace (POST <api>/runs).
2
+ // The upload is a COPY of evidence that is already on disk. It never changes the verdict or the exit code: a
3
+ // failed upload is a warning on stderr, and the local evidence file stays the record.
4
+ import { spawnSync } from 'node:child_process'
5
+ import { gzipSync } from 'node:zlib'
6
+ import path from 'node:path'
7
+ import { TOOL_VERSION } from './runner.mjs'
8
+
9
+ export const DEFAULT_API = 'https://developer.finipe.com/api/v1/fini-proof'
10
+ // Same rules the server applies (backend fini-proof-evidence.ts), so a bad name fails here with a clear message.
11
+ const REPOSITORY_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/
12
+ const BRANCH_RE = /^[^\s\x00-\x1f]{1,255}$/
13
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
14
+
15
+ function git(root, args) {
16
+ const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' })
17
+ return r.status === 0 ? r.stdout.trim() : ''
18
+ }
19
+
20
+ /** `owner/name` from the origin remote (https, ssh or scp form), else the directory name. */
21
+ export function repositoryName(root, explicit) {
22
+ if (explicit) return String(explicit).trim()
23
+ if (process.env.FINI_PROOF_REPOSITORY) return process.env.FINI_PROOF_REPOSITORY.trim()
24
+ const url = git(root, ['remote', 'get-url', 'origin'])
25
+ const m = /[:/]([^/:]+\/[^/]+?)(?:\.git)?\/?$/.exec(url)
26
+ if (m) return m[1]
27
+ return path.basename(path.resolve(root))
28
+ }
29
+
30
+ function branchName(root) {
31
+ const env = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_REF_NAME || process.env.BITBUCKET_BRANCH || process.env.BRANCH_NAME
32
+ const b = env || git(root, ['rev-parse', '--abbrev-ref', 'HEAD'])
33
+ return b && b !== 'HEAD' && BRANCH_RE.test(b) ? b : null
34
+ }
35
+
36
+ /**
37
+ * Upload `result` (the evidence object `check` just wrote). Returns { ok, status?, runId?, error? } and never throws.
38
+ * The API key is sent only in the Authorization header, and only over https (plain http is allowed for a local
39
+ * server only, so a key is never sent in clear text across a network).
40
+ */
41
+ /** Opt-in gzip: `--upload-gzip` or FINI_PROOF_UPLOAD_GZIP=1. Opt-in because the server must accept Content-Encoding. */
42
+ export const gzipWanted = (flag, env = process.env) => flag === true || /^(1|true|yes)$/i.test(env.FINI_PROOF_UPLOAD_GZIP || '')
43
+
44
+ export async function uploadEvidence(result, { root, repository, api, key, timeoutMs = 15_000, gzip = false } = {}) {
45
+ const apiKey = key ?? process.env.FINI_PROOF_API_KEY
46
+ if (!apiKey) return { ok: false, error: 'FINI_PROOF_API_KEY is not set' }
47
+ const base = String(api || process.env.FINI_PROOF_API || DEFAULT_API).replace(/\/+$/, '')
48
+ let url
49
+ try { url = new URL(`${base}/runs`) } catch { return { ok: false, error: `FINI_PROOF_API is not a URL: ${base}` } }
50
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname))) {
51
+ return { ok: false, error: `refusing to send the API key over ${url.protocol} to ${url.hostname} (https only, http only for localhost)` }
52
+ }
53
+ const name = repositoryName(root, repository)
54
+ if (!REPOSITORY_RE.test(name)) return { ok: false, error: `repository name "${name}" is not valid — set --repository <owner/name> or FINI_PROOF_REPOSITORY` }
55
+ const branch = branchName(root)
56
+ const payload = JSON.stringify({ ...result, repository: name, source: process.env.CI ? 'ci' : 'cli', ...(branch ? { branch } : {}) })
57
+ // Evidence grows ~156 bytes per checked file (per-file sha256 + per-engine scope, both needed for STALE); gzip cuts
58
+ // it ~3x. The bytes the server verifies are the JSON after decompression, so the digest is unaffected.
59
+ const body = gzip ? gzipSync(payload) : payload
60
+
61
+ const ctrl = new AbortController()
62
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs)
63
+ try {
64
+ const res = await fetch(url, {
65
+ method: 'POST',
66
+ headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', ...(gzip ? { 'content-encoding': 'gzip' } : {}), 'user-agent': `fini-proof/${TOOL_VERSION}` },
67
+ body,
68
+ signal: ctrl.signal,
69
+ })
70
+ const text = await res.text()
71
+ let json = null
72
+ try { json = JSON.parse(text) } catch { json = null }
73
+ if (!res.ok) {
74
+ const msg = json?.message || json?.error?.message || json?.code || text
75
+ const hint = res.status === 413 && !gzip ? ` (evidence is ${Buffer.byteLength(payload)} bytes; retry with --upload-gzip if the workspace accepts gzip)` : ''
76
+ return { ok: false, status: res.status, error: `HTTP ${res.status}${msg ? ` — ${String(Array.isArray(msg) ? msg.join('; ') : typeof msg === 'string' ? msg : JSON.stringify(msg)).slice(0, 300)}` : ''}${hint}` }
77
+ }
78
+ const data = json && typeof json === 'object' && json.data && typeof json.data === 'object' ? json.data : json
79
+ return { ok: true, status: res.status, runId: data?.id ?? null }
80
+ } catch (e) {
81
+ return { ok: false, error: e.name === 'AbortError' ? `timed out after ${timeoutMs} ms` : (e.cause?.code || e.message) }
82
+ } finally {
83
+ clearTimeout(timer)
84
+ }
85
+ }