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/LICENSE +5 -0
- package/README.md +220 -0
- package/action.yml +53 -0
- package/bin/fini-proof.mjs +6 -0
- package/ci/Jenkinsfile +13 -0
- package/ci/Jenkinsfile.daily +23 -0
- package/ci/bitbucket-pipelines-daily.yml +15 -0
- package/ci/bitbucket-pipelines.yml +12 -0
- package/ci/github-actions-daily.yml +31 -0
- package/ci/github-actions-example.yml +14 -0
- package/ci/gitlab-ci-daily.yml +17 -0
- package/ci/gitlab-ci.yml +13 -0
- package/package.json +31 -0
- package/src/cli.mjs +258 -0
- package/src/engines/fail-open.mjs +133 -0
- package/src/engines/gitleaks.mjs +143 -0
- package/src/engines/hollow-tests.mjs +121 -0
- package/src/engines/migrations.mjs +316 -0
- package/src/engines/secrets.mjs +88 -0
- package/src/engines/skip-ratchet.mjs +78 -0
- package/src/engines/tenant-filter.mjs +516 -0
- package/src/engines/vendor/entrypoint-guard-core.mjs +43 -0
- package/src/engines/vendor/migration-invariants-core.mjs +127 -0
- package/src/engines/vendor/secrets-core.mjs +123 -0
- package/src/engines/vendor/vacuous-spec-core.mjs +1066 -0
- package/src/evidence.mjs +130 -0
- package/src/files.mjs +89 -0
- package/src/hooks.mjs +118 -0
- package/src/licence.mjs +85 -0
- package/src/mcp.mjs +230 -0
- package/src/report.mjs +73 -0
- package/src/runner.mjs +306 -0
- package/src/state.mjs +189 -0
- package/src/upload.mjs +85 -0
- package/src/usage.mjs +64 -0
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
|
+
}
|