@clear-capabilities/agentic-security-scanner 0.160.0 → 0.162.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/CHANGELOG.md +38 -0
- package/bin/agentic-security.js +11 -2
- package/dist/1919.index.js +1 -1
- package/dist/2432.index.js +10 -1
- package/dist/4113.index.js +60 -9
- package/dist/5613.index.js +500 -0
- package/dist/6239.index.js +469 -0
- package/dist/7033.index.js +4 -1
- package/dist/7838.index.js +39 -3
- package/dist/8714.index.js +1 -1
- package/dist/8798.index.js +2 -2
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +205 -62
- package/dist/{1905.index.js → 9999.index.js} +265 -8
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +14 -9
- package/src/dataflow/engine.js +20 -4
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +28 -0
- package/src/discovery/memory.js +7 -5
- package/src/engine.js +29 -8
- package/src/fix/apply-fix-service.js +33 -3
- package/src/language/CLAUDE.md +6 -1
- package/src/language/aibom.js +4 -1
- package/src/language/fix-lifecycle.js +9 -0
- package/src/language/haskell-ir.js +47 -7
- package/src/language/haskell-sca.js +87 -7
- package/src/language/haskell-supply.js +175 -6
- package/src/language/haskell-web.js +200 -18
- package/src/language/nix-fix.js +1 -1
- package/src/language/resolved-pass.js +4 -1
- package/src/language/witness.js +1 -1
- package/src/mcp/CLAUDE.md +43 -4
- package/src/mcp/tools.js +185 -43
- package/src/posture/CLAUDE.md +174 -0
- package/src/posture/assurance/baseline-inventory.js +218 -0
- package/src/posture/assurance/baseline.js +322 -0
- package/src/posture/assurance/bounded-io.js +145 -0
- package/src/posture/assurance/config.js +299 -0
- package/src/posture/assurance/contracts.js +315 -0
- package/src/posture/assurance/identity.js +62 -0
- package/src/posture/assurance/migrations.js +259 -0
- package/src/posture/assurance/schema-kit.js +111 -0
- package/src/posture/assurance/verification-record.js +258 -0
- package/src/posture/attestation.js +5 -0
- package/src/posture/autopilot.js +28 -1
- package/src/posture/blast-radius.js +15 -1
- package/src/posture/evidence-bundle.js +93 -7
- package/src/posture/execution-proof.js +11 -1
- package/src/posture/fix-verify-loop.js +20 -6
- package/src/posture/fix-verify.js +37 -2
- package/src/posture/oracles/adapters.js +360 -0
- package/src/posture/oracles/conformance.js +275 -0
- package/src/posture/oracles/oracle.js +433 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/remediation-ledger.js +10 -1
- package/src/posture/replay/replay.js +249 -0
- package/src/posture/verification/advisory-state.js +71 -0
- package/src/posture/verification/emit.js +257 -0
- package/src/posture/verification/hypothesis-promotion.js +211 -0
- package/src/posture/verification/patch-negative.js +214 -0
- package/src/posture/verification/patch-promotion.js +105 -0
- package/src/posture/verification/projection.js +183 -0
- package/src/posture/verification/repair-records.js +124 -0
- package/src/posture/verifier.js +16 -0
- package/src/report/index.js +49 -0
- package/src/sandbox/CLAUDE.md +41 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +68 -29
- package/src/sandbox/control-probes.js +215 -0
- package/src/sandbox/supervise.js +201 -0
- package/src/sandbox/trust-boundary.js +113 -0
- package/src/sandbox/trust-domains.js +171 -0
- package/src/sast/pipeline.js +51 -4
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Small validation primitives shared by the assurance evidence contracts
|
|
2
|
+
// (CORE-002). Pure: no fs, no clock, no network. Every validator returns
|
|
3
|
+
// `{ ok, errors }` where each error is `{ code, path, message }`; nothing throws
|
|
4
|
+
// on bad input, because a validator that throws on hostile input is a denial of
|
|
5
|
+
// service on whatever called it.
|
|
6
|
+
|
|
7
|
+
export const ERROR_CODES = Object.freeze([
|
|
8
|
+
'NOT_AN_OBJECT', 'SCHEMA_MISMATCH', 'UNSUPPORTED_MAJOR', 'BAD_VERSION', 'MISSING_FIELD', 'UNKNOWN_FIELD',
|
|
9
|
+
'BAD_TYPE', 'UNKNOWN_ENUM', 'BAD_DIGEST', 'BAD_ID', 'ID_MISMATCH', 'DUPLICATE_ID', 'DANGLING_REF', 'RULE_VIOLATION',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export const SUPPORTED_MAJOR = 1;
|
|
13
|
+
export const SCHEMA_VERSION = '1.0.0';
|
|
14
|
+
|
|
15
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
16
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
17
|
+
const COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
18
|
+
|
|
19
|
+
export function makeCtx() {
|
|
20
|
+
const errors = [];
|
|
21
|
+
return {
|
|
22
|
+
errors,
|
|
23
|
+
err(code, path, message) { errors.push({ code, path, message }); },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isPlainObject(v) {
|
|
28
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isDigest(v) { return typeof v === 'string' && DIGEST.test(v); }
|
|
32
|
+
export function isCommit(v) { return typeof v === 'string' && COMMIT.test(v); }
|
|
33
|
+
export function isNonEmptyString(v) { return typeof v === 'string' && v.trim().length > 0; }
|
|
34
|
+
|
|
35
|
+
/** Header check: `schema` must match and the major version must be supported. */
|
|
36
|
+
export function checkHeader(ctx, rec, schemaName) {
|
|
37
|
+
if (rec.schema !== schemaName) {
|
|
38
|
+
ctx.err('SCHEMA_MISMATCH', 'schema', `expected schema '${schemaName}', got ${JSON.stringify(rec.schema)}`);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const m = typeof rec.schemaVersion === 'string' ? SEMVER.exec(rec.schemaVersion) : null;
|
|
42
|
+
if (!m) {
|
|
43
|
+
ctx.err('BAD_VERSION', 'schemaVersion', `schemaVersion must be a semantic version, got ${JSON.stringify(rec.schemaVersion)}`);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (Number(m[1]) !== SUPPORTED_MAJOR) {
|
|
47
|
+
ctx.err('UNSUPPORTED_MAJOR', 'schemaVersion', `major version ${m[1]} is not supported (supported: ${SUPPORTED_MAJOR})`);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Closed world: any field outside `allowed` is rejected, never silently carried. */
|
|
54
|
+
export function checkFields(ctx, rec, allowed, required) {
|
|
55
|
+
for (const k of Object.keys(rec)) {
|
|
56
|
+
if (!allowed.includes(k)) ctx.err('UNKNOWN_FIELD', k, `field '${k}' is not part of this schema`);
|
|
57
|
+
}
|
|
58
|
+
for (const k of required) {
|
|
59
|
+
if (rec[k] === undefined) ctx.err('MISSING_FIELD', k, `required field '${k}' is missing`);
|
|
60
|
+
}
|
|
61
|
+
// `createdAt` is informational and never enters an id; it must still be a string.
|
|
62
|
+
if (rec.createdAt !== undefined && typeof rec.createdAt !== 'string') ctx.err('BAD_TYPE', 'createdAt', 'must be a string');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function checkEnum(ctx, path, value, allowed) {
|
|
66
|
+
if (typeof value !== 'string' || !allowed.includes(value)) {
|
|
67
|
+
ctx.err('UNKNOWN_ENUM', path, `${JSON.stringify(value)} is not one of: ${allowed.join(', ')}`);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function checkDigest(ctx, path, value, { nullable = false } = {}) {
|
|
74
|
+
if (value === null && nullable) return true;
|
|
75
|
+
if (!isDigest(value)) { ctx.err('BAD_DIGEST', path, `must be 'sha256:' followed by 64 lowercase hex characters`); return false; }
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function checkCommit(ctx, path, value, { nullable = true } = {}) {
|
|
80
|
+
if (value === null && nullable) return true;
|
|
81
|
+
if (!isCommit(value)) { ctx.err('BAD_TYPE', path, 'must be a 40 or 64 character lowercase hex commit id'); return false; }
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function checkString(ctx, path, value) {
|
|
86
|
+
if (!isNonEmptyString(value)) { ctx.err('BAD_TYPE', path, 'must be a non-empty string'); return false; }
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function checkId(ctx, rec, expectedId) {
|
|
91
|
+
const prefix = expectedId.split(':')[0];
|
|
92
|
+
if (typeof rec.id !== 'string' || !rec.id.startsWith(`${prefix}:`)) {
|
|
93
|
+
ctx.err('BAD_ID', 'id', `id must start with '${prefix}:'`);
|
|
94
|
+
} else if (rec.id !== expectedId) {
|
|
95
|
+
ctx.err('ID_MISMATCH', 'id', `id does not match the record's semantic content (expected ${expectedId})`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function result(ctx) {
|
|
100
|
+
return { ok: ctx.errors.length === 0, errors: ctx.errors };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Reject non-object input with one typed error instead of throwing downstream. */
|
|
104
|
+
export function guardObject(rec) {
|
|
105
|
+
const ctx = makeCtx();
|
|
106
|
+
if (!isPlainObject(rec)) {
|
|
107
|
+
ctx.err('NOT_AN_OBJECT', '', 'record must be a plain object');
|
|
108
|
+
return { ctx, ok: false };
|
|
109
|
+
}
|
|
110
|
+
return { ctx, ok: true };
|
|
111
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// The one verification record (CORE-002; X-201 builds every surface on it).
|
|
2
|
+
//
|
|
3
|
+
// Shape: hypothesis id, commit, detector origin, oracle, attempt, outcome,
|
|
4
|
+
// reason, evidence items + references, applicable scope, and a SEPARATE repair
|
|
5
|
+
// status.
|
|
6
|
+
//
|
|
7
|
+
// Structural honesty rules, enforced by the validator (not by convention):
|
|
8
|
+
// 1. The six outcomes are distinct: not-run, unsupported, inconclusive, error,
|
|
9
|
+
// refuted, confirmed. Nothing here collapses them to a boolean.
|
|
10
|
+
// 2. Evidence kinds are distinct: observation, inference, independent
|
|
11
|
+
// adjudication, trusted runtime proof. Each kind constrains who may have
|
|
12
|
+
// produced it: a model can only ever produce an inference; trusted runtime
|
|
13
|
+
// proof only comes from a trusted runner; an adjudication only from a human
|
|
14
|
+
// or an independent verifier.
|
|
15
|
+
// 3. `confirmed` needs referenced evidence of runtime proof or independent
|
|
16
|
+
// adjudication, so a model-generated verdict alone cannot yield it, and the
|
|
17
|
+
// record carries a `confirmationLevel` that must equal the level derived from
|
|
18
|
+
// its evidence ('runtime-confirmed' needs trusted-runtime-proof AND a runtime
|
|
19
|
+
// oracle). A forged level fails the derivation check.
|
|
20
|
+
// 4. `refuted` needs an applicable (non-model) oracle, valid preconditions and
|
|
21
|
+
// proving evidence: a missing taint path is not refutation of an
|
|
22
|
+
// authorization or workflow claim.
|
|
23
|
+
// 5. Repair status is a different axis from the outcome. `replay-verified`
|
|
24
|
+
// needs the id of the patched-negative verification record.
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
SCHEMA_VERSION, makeCtx, result, guardObject, checkHeader, checkFields, checkEnum, checkDigest,
|
|
28
|
+
checkCommit, checkString, checkId, isPlainObject,
|
|
29
|
+
} from './schema-kit.js';
|
|
30
|
+
import { semanticId, ID_PREFIXES } from './identity.js';
|
|
31
|
+
|
|
32
|
+
export const VERIFICATION_SCHEMA = 'agentic-security/verification-record';
|
|
33
|
+
|
|
34
|
+
export const VERIFICATION_OUTCOMES = Object.freeze(['not-run', 'unsupported', 'inconclusive', 'error', 'refuted', 'confirmed']);
|
|
35
|
+
export const REPAIR_STATUSES = Object.freeze(['none', 'proposed', 'applied', 'replay-verified', 'rolled-back']);
|
|
36
|
+
export const EVIDENCE_KINDS = Object.freeze(['observation', 'inference', 'independent-adjudication', 'trusted-runtime-proof']);
|
|
37
|
+
export const EVIDENCE_PRODUCERS = Object.freeze(['model', 'tool', 'human', 'independent-verifier', 'trusted-runner']);
|
|
38
|
+
export const ORACLE_KINDS = Object.freeze(['runtime-replay', 'differential', 'static-proof', 'model', 'human', 'none']);
|
|
39
|
+
export const CONFIRMATION_LEVELS = Object.freeze(['runtime-confirmed', 'adjudicated', 'none']);
|
|
40
|
+
|
|
41
|
+
const RUNTIME_ORACLES = ['runtime-replay', 'differential'];
|
|
42
|
+
const APPLICABLE_FOR_REFUTATION = ['runtime-replay', 'differential', 'static-proof', 'human'];
|
|
43
|
+
const NO_EXECUTION_OUTCOMES = ['not-run', 'unsupported'];
|
|
44
|
+
|
|
45
|
+
const ALLOWED = [
|
|
46
|
+
'schema', 'schemaVersion', 'id', 'hypothesisId', 'commit', 'detectorOrigin', 'oracle', 'attempt', 'outcome', 'reason',
|
|
47
|
+
'evidence', 'evidenceRefs', 'scope', 'preconditions', 'confirmationLevel', 'repair', 'migration', 'createdAt',
|
|
48
|
+
];
|
|
49
|
+
const REQUIRED = [
|
|
50
|
+
'schema', 'schemaVersion', 'id', 'hypothesisId', 'commit', 'detectorOrigin', 'oracle', 'attempt', 'outcome', 'reason',
|
|
51
|
+
'evidence', 'evidenceRefs', 'scope', 'preconditions', 'confirmationLevel', 'repair',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// Fields that define WHAT was claimed and how it was checked. `createdAt` (a
|
|
55
|
+
// clock) and `migration` (how the record was represented) are deliberately not
|
|
56
|
+
// here, so neither can move an id.
|
|
57
|
+
export const VERIFICATION_ID_FIELDS = Object.freeze([
|
|
58
|
+
'hypothesisId', 'commit', 'detectorOrigin', 'oracle', 'attempt', 'outcome', 'reason', 'evidence', 'evidenceRefs',
|
|
59
|
+
'scope', 'preconditions', 'confirmationLevel', 'repair',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
export function verificationRecordId(record) {
|
|
63
|
+
return semanticId(ID_PREFIXES.verification, record, VERIFICATION_ID_FIELDS);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The identity of the CLAIM (which hypothesis, at which commit, by which oracle, on which attempt), excluding its result. */
|
|
67
|
+
export function verificationClaimKey(record) {
|
|
68
|
+
return semanticId('vclaim', record, ['hypothesisId', 'commit', 'oracle', 'attempt']);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function referencedEvidence(record) {
|
|
72
|
+
const refs = new Set(Array.isArray(record.evidenceRefs) ? record.evidenceRefs : []);
|
|
73
|
+
return (Array.isArray(record.evidence) ? record.evidence : []).filter(e => isPlainObject(e) && refs.has(e.id));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The confirmation level the evidence actually supports. Never read from the record's own claim. */
|
|
77
|
+
export function deriveConfirmationLevel(record) {
|
|
78
|
+
if (!record || record.outcome !== 'confirmed') return 'none';
|
|
79
|
+
const ev = referencedEvidence(record);
|
|
80
|
+
const runtime = ev.some(e => e.kind === 'trusted-runtime-proof' && e.producer === 'trusted-runner');
|
|
81
|
+
if (runtime && RUNTIME_ORACLES.includes(record.oracle?.kind)) return 'runtime-confirmed';
|
|
82
|
+
const adjudicated = ev.some(e => e.kind === 'independent-adjudication' && ['human', 'independent-verifier'].includes(e.producer));
|
|
83
|
+
return adjudicated ? 'adjudicated' : 'none';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function validateEvidenceItem(ctx, e, i) {
|
|
87
|
+
const p = `evidence[${i}]`;
|
|
88
|
+
if (!isPlainObject(e)) { ctx.err('BAD_TYPE', p, 'evidence item must be an object'); return; }
|
|
89
|
+
for (const k of Object.keys(e)) {
|
|
90
|
+
if (!['id', 'kind', 'producer', 'digest', 'source'].includes(k)) ctx.err('UNKNOWN_FIELD', `${p}.${k}`, `field '${k}' is not part of an evidence item`);
|
|
91
|
+
}
|
|
92
|
+
checkString(ctx, `${p}.id`, e.id);
|
|
93
|
+
const kindOk = checkEnum(ctx, `${p}.kind`, e.kind, EVIDENCE_KINDS);
|
|
94
|
+
const prodOk = checkEnum(ctx, `${p}.producer`, e.producer, EVIDENCE_PRODUCERS);
|
|
95
|
+
checkDigest(ctx, `${p}.digest`, e.digest);
|
|
96
|
+
if (e.source !== undefined && e.source !== null) checkString(ctx, `${p}.source`, e.source);
|
|
97
|
+
if (!kindOk || !prodOk) return;
|
|
98
|
+
if (e.producer === 'model' && e.kind !== 'inference') {
|
|
99
|
+
ctx.err('RULE_VIOLATION', p, `a model-produced item can only be 'inference', not '${e.kind}'`);
|
|
100
|
+
}
|
|
101
|
+
if (e.kind === 'trusted-runtime-proof' && e.producer !== 'trusted-runner') {
|
|
102
|
+
ctx.err('RULE_VIOLATION', p, `'trusted-runtime-proof' can only be produced by a 'trusted-runner', not '${e.producer}'`);
|
|
103
|
+
}
|
|
104
|
+
if (e.kind === 'independent-adjudication' && !['human', 'independent-verifier'].includes(e.producer)) {
|
|
105
|
+
ctx.err('RULE_VIOLATION', p, `'independent-adjudication' can only be produced by a 'human' or 'independent-verifier', not '${e.producer}'`);
|
|
106
|
+
}
|
|
107
|
+
if (e.producer === 'trusted-runner' && !['trusted-runtime-proof', 'observation'].includes(e.kind)) {
|
|
108
|
+
ctx.err('RULE_VIOLATION', p, `a 'trusted-runner' produces 'trusted-runtime-proof' or 'observation', not '${e.kind}'`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function validateVerificationRecord(record) {
|
|
113
|
+
const g = guardObject(record);
|
|
114
|
+
const ctx = g.ctx;
|
|
115
|
+
if (!g.ok) return result(ctx);
|
|
116
|
+
if (!checkHeader(ctx, record, VERIFICATION_SCHEMA)) return result(ctx);
|
|
117
|
+
checkFields(ctx, record, ALLOWED, REQUIRED);
|
|
118
|
+
|
|
119
|
+
checkString(ctx, 'hypothesisId', record.hypothesisId);
|
|
120
|
+
checkCommit(ctx, 'commit', record.commit);
|
|
121
|
+
|
|
122
|
+
const origin = record.detectorOrigin;
|
|
123
|
+
if (!isPlainObject(origin)) ctx.err('BAD_TYPE', 'detectorOrigin', 'must be an object with a detector name');
|
|
124
|
+
else {
|
|
125
|
+
for (const k of Object.keys(origin)) if (!['detector', 'family', 'parser'].includes(k)) ctx.err('UNKNOWN_FIELD', `detectorOrigin.${k}`, 'not part of detectorOrigin');
|
|
126
|
+
checkString(ctx, 'detectorOrigin.detector', origin.detector);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const oracle = record.oracle;
|
|
130
|
+
let oracleKind = null;
|
|
131
|
+
if (oracle === null) oracleKind = 'none';
|
|
132
|
+
else if (!isPlainObject(oracle)) ctx.err('BAD_TYPE', 'oracle', 'must be an object or null');
|
|
133
|
+
else {
|
|
134
|
+
for (const k of Object.keys(oracle)) if (!['id', 'kind', 'version'].includes(k)) ctx.err('UNKNOWN_FIELD', `oracle.${k}`, 'not part of oracle');
|
|
135
|
+
checkString(ctx, 'oracle.id', oracle.id);
|
|
136
|
+
if (checkEnum(ctx, 'oracle.kind', oracle.kind, ORACLE_KINDS)) oracleKind = oracle.kind;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!Number.isInteger(record.attempt) || record.attempt < 0) ctx.err('BAD_TYPE', 'attempt', 'must be a non-negative integer');
|
|
140
|
+
const outcomeOk = checkEnum(ctx, 'outcome', record.outcome, VERIFICATION_OUTCOMES);
|
|
141
|
+
checkString(ctx, 'reason', record.reason);
|
|
142
|
+
|
|
143
|
+
// evidence items
|
|
144
|
+
const ids = new Set();
|
|
145
|
+
if (!Array.isArray(record.evidence)) ctx.err('BAD_TYPE', 'evidence', 'must be an array');
|
|
146
|
+
else {
|
|
147
|
+
record.evidence.forEach((e, i) => {
|
|
148
|
+
validateEvidenceItem(ctx, e, i);
|
|
149
|
+
if (isPlainObject(e) && typeof e.id === 'string') {
|
|
150
|
+
if (ids.has(e.id)) ctx.err('DUPLICATE_ID', `evidence[${i}].id`, `duplicate evidence id '${e.id}'`);
|
|
151
|
+
ids.add(e.id);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (!Array.isArray(record.evidenceRefs)) ctx.err('BAD_TYPE', 'evidenceRefs', 'must be an array of evidence ids');
|
|
156
|
+
else {
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
record.evidenceRefs.forEach((r, i) => {
|
|
159
|
+
if (!ids.has(r)) ctx.err('DANGLING_REF', `evidenceRefs[${i}]`, `evidence reference '${r}' does not resolve to an evidence item in this record`);
|
|
160
|
+
if (seen.has(r)) ctx.err('DUPLICATE_ID', `evidenceRefs[${i}]`, `evidence reference '${r}' listed twice`);
|
|
161
|
+
seen.add(r);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// scope
|
|
166
|
+
const scope = record.scope;
|
|
167
|
+
if (!isPlainObject(scope)) ctx.err('BAD_TYPE', 'scope', 'must be an object');
|
|
168
|
+
else {
|
|
169
|
+
for (const k of Object.keys(scope)) if (!['description', 'platform', 'backend'].includes(k)) ctx.err('UNKNOWN_FIELD', `scope.${k}`, 'not part of scope');
|
|
170
|
+
checkString(ctx, 'scope.description', scope.description);
|
|
171
|
+
checkString(ctx, 'scope.platform', scope.platform);
|
|
172
|
+
if (scope.backend !== undefined && scope.backend !== null) checkString(ctx, 'scope.backend', scope.backend);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// preconditions
|
|
176
|
+
const pre = record.preconditions;
|
|
177
|
+
if (!isPlainObject(pre) || typeof pre.valid !== 'boolean') ctx.err('BAD_TYPE', 'preconditions', 'must be an object with a boolean `valid`');
|
|
178
|
+
|
|
179
|
+
// repair: a separate axis
|
|
180
|
+
const repair = record.repair;
|
|
181
|
+
if (!isPlainObject(repair)) ctx.err('BAD_TYPE', 'repair', 'must be an object');
|
|
182
|
+
else {
|
|
183
|
+
for (const k of Object.keys(repair)) if (!['status', 'patchDigest', 'replayRecordId'].includes(k)) ctx.err('UNKNOWN_FIELD', `repair.${k}`, 'not part of repair');
|
|
184
|
+
if (checkEnum(ctx, 'repair.status', repair.status, REPAIR_STATUSES)) {
|
|
185
|
+
if (['applied', 'replay-verified', 'rolled-back'].includes(repair.status)) {
|
|
186
|
+
if (repair.patchDigest === undefined || repair.patchDigest === null) ctx.err('RULE_VIOLATION', 'repair.patchDigest', `repair status '${repair.status}' requires a patchDigest`);
|
|
187
|
+
}
|
|
188
|
+
if (repair.patchDigest !== undefined && repair.patchDigest !== null) checkDigest(ctx, 'repair.patchDigest', repair.patchDigest);
|
|
189
|
+
if (repair.status === 'replay-verified' && !(typeof repair.replayRecordId === 'string' && repair.replayRecordId.startsWith('vrec:'))) {
|
|
190
|
+
ctx.err('RULE_VIOLATION', 'repair.replayRecordId', `repair status 'replay-verified' requires the id of the patched-negative verification record`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// confirmation level must be what the evidence supports
|
|
196
|
+
const levelOk = checkEnum(ctx, 'confirmationLevel', record.confirmationLevel, CONFIRMATION_LEVELS);
|
|
197
|
+
if (levelOk) {
|
|
198
|
+
const derived = deriveConfirmationLevel(record);
|
|
199
|
+
if (record.confirmationLevel !== derived) {
|
|
200
|
+
ctx.err('RULE_VIOLATION', 'confirmationLevel', `claimed '${record.confirmationLevel}' but the referenced evidence supports '${derived}'`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// outcome rules
|
|
205
|
+
if (outcomeOk) {
|
|
206
|
+
const refEv = referencedEvidence(record);
|
|
207
|
+
const hasProof = refEv.some(e => ['trusted-runtime-proof', 'independent-adjudication'].includes(e.kind));
|
|
208
|
+
const attempt = record.attempt;
|
|
209
|
+
if (!NO_EXECUTION_OUTCOMES.includes(record.outcome) && Number.isInteger(attempt) && attempt < 1) {
|
|
210
|
+
ctx.err('RULE_VIOLATION', 'attempt', `outcome '${record.outcome}' requires attempt >= 1`);
|
|
211
|
+
}
|
|
212
|
+
if (NO_EXECUTION_OUTCOMES.includes(record.outcome)) {
|
|
213
|
+
if (Number.isInteger(attempt) && attempt !== 0) ctx.err('RULE_VIOLATION', 'attempt', `outcome '${record.outcome}' means nothing ran, so attempt must be 0`);
|
|
214
|
+
if (refEv.some(e => e.kind === 'trusted-runtime-proof')) ctx.err('RULE_VIOLATION', 'evidence', `outcome '${record.outcome}' cannot cite runtime proof: nothing executed`);
|
|
215
|
+
}
|
|
216
|
+
if (['confirmed', 'refuted'].includes(record.outcome)) {
|
|
217
|
+
if (record.commit === null) ctx.err('RULE_VIOLATION', 'commit', `outcome '${record.outcome}' must be bound to an exact commit`);
|
|
218
|
+
if (oracleKind === 'none') ctx.err('RULE_VIOLATION', 'oracle', `outcome '${record.outcome}' requires an oracle`);
|
|
219
|
+
if (oracleKind === 'model') ctx.err('RULE_VIOLATION', 'oracle', `a model oracle can never yield '${record.outcome}'`);
|
|
220
|
+
if (!hasProof) ctx.err('RULE_VIOLATION', 'evidenceRefs', `outcome '${record.outcome}' requires referenced trusted-runtime-proof or independent-adjudication evidence`);
|
|
221
|
+
}
|
|
222
|
+
if (record.outcome === 'confirmed' && record.confirmationLevel === 'none') {
|
|
223
|
+
ctx.err('RULE_VIOLATION', 'confirmationLevel', `a 'confirmed' record must carry a confirmation level above 'none'`);
|
|
224
|
+
}
|
|
225
|
+
if (record.outcome === 'refuted') {
|
|
226
|
+
if (oracleKind && !APPLICABLE_FOR_REFUTATION.includes(oracleKind)) ctx.err('RULE_VIOLATION', 'oracle', `oracle kind '${oracleKind}' is not applicable for refutation`);
|
|
227
|
+
if (!(isPlainObject(pre) && pre.valid === true)) ctx.err('RULE_VIOLATION', 'preconditions', `'refuted' requires valid preconditions: a check that could not run is not a refutation`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
checkId(ctx, record, verificationRecordId(record));
|
|
232
|
+
return result(ctx);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Build a complete, valid-by-construction record from the semantic fields; derives the level and the id. */
|
|
236
|
+
export function buildVerificationRecord(fields) {
|
|
237
|
+
const rec = {
|
|
238
|
+
schema: VERIFICATION_SCHEMA,
|
|
239
|
+
schemaVersion: SCHEMA_VERSION,
|
|
240
|
+
hypothesisId: fields.hypothesisId,
|
|
241
|
+
commit: fields.commit ?? null,
|
|
242
|
+
detectorOrigin: fields.detectorOrigin,
|
|
243
|
+
oracle: fields.oracle ?? null,
|
|
244
|
+
attempt: fields.attempt ?? 0,
|
|
245
|
+
outcome: fields.outcome,
|
|
246
|
+
reason: fields.reason,
|
|
247
|
+
evidence: fields.evidence ?? [],
|
|
248
|
+
evidenceRefs: fields.evidenceRefs ?? (fields.evidence ?? []).map(e => e.id),
|
|
249
|
+
scope: fields.scope,
|
|
250
|
+
preconditions: fields.preconditions ?? { valid: false },
|
|
251
|
+
repair: fields.repair ?? { status: 'none' },
|
|
252
|
+
};
|
|
253
|
+
rec.confirmationLevel = deriveConfirmationLevel(rec);
|
|
254
|
+
if (fields.migration) rec.migration = fields.migration;
|
|
255
|
+
if (fields.createdAt) rec.createdAt = fields.createdAt;
|
|
256
|
+
rec.id = verificationRecordId(rec);
|
|
257
|
+
return rec;
|
|
258
|
+
}
|
|
@@ -143,6 +143,11 @@ export function computeRunAttestation({
|
|
|
143
143
|
try {
|
|
144
144
|
att.signature = signLastScan(digest);
|
|
145
145
|
att.signatureScope = 'per-install-hmac';
|
|
146
|
+
// CORE-003.AC03: say plainly who vouches. A symmetric install HMAC can be
|
|
147
|
+
// produced by anyone who can verify it, so it is self-issued by
|
|
148
|
+
// construction and can never be third-party certification.
|
|
149
|
+
att.trustBasis = 'self-issued-local-key';
|
|
150
|
+
att.independentlyCertified = false;
|
|
146
151
|
} catch { /* signing is best-effort; an unsigned attestation is still valid */ }
|
|
147
152
|
}
|
|
148
153
|
return att;
|
package/src/posture/autopilot.js
CHANGED
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
|
|
27
27
|
import fs from 'node:fs';
|
|
28
28
|
import path from 'node:path';
|
|
29
|
+
import { emitVerification } from './verification/emit.js';
|
|
30
|
+
import { verificationFields } from './verification/projection.js';
|
|
29
31
|
|
|
30
32
|
const SCHEMA = 'agentic-security/autopilot@1';
|
|
31
33
|
|
|
@@ -73,8 +75,15 @@ function _save(stateFile, state) {
|
|
|
73
75
|
*/
|
|
74
76
|
export async function runAutopilot({
|
|
75
77
|
stages = {}, stateFile = null, resume = true, apply = false,
|
|
76
|
-
onStage = () => {}, severities = ['critical', 'high'], maxFindings = Infinity,
|
|
78
|
+
onStage = () => {}, severities = ['critical', 'high'], maxFindings = Infinity, commit = null,
|
|
77
79
|
} = {}) {
|
|
80
|
+
// X-201: every settled finding carries the one version-1 verification record, ADDITIVELY (`outcome`, `reason` and
|
|
81
|
+
// every other field are unchanged). The chain's own proof and re-verify stages run under the older sandbox path, so
|
|
82
|
+
// the record never says `confirmed` or `refuted` here: it records what was observed and what remains unproven.
|
|
83
|
+
const seal = (rec, finding, patch) => {
|
|
84
|
+
const e = emitVerification('autopilot', rec, { finding, patch: patch?.patch, commit });
|
|
85
|
+
if (e.ok) rec.verificationRecord = e.record;
|
|
86
|
+
};
|
|
78
87
|
const required = ['scan'];
|
|
79
88
|
for (const r of required) {
|
|
80
89
|
if (typeof stages[r] !== 'function') return { ok: false, reason: `no ${r} stage supplied` };
|
|
@@ -138,6 +147,7 @@ export async function runAutopilot({
|
|
|
138
147
|
if (rec.validation === 'refuted') {
|
|
139
148
|
rec.outcome = 'NEEDS_REVIEW';
|
|
140
149
|
rec.reason = 'an independent verifier refuted this finding; not fixed automatically';
|
|
150
|
+
seal(rec, f, null);
|
|
141
151
|
results.push(rec); state.findings[key] = rec; _save(stateFile, state);
|
|
142
152
|
onStage({ stage: 'validate', key, verdict: 'refuted' });
|
|
143
153
|
continue;
|
|
@@ -149,6 +159,7 @@ export async function runAutopilot({
|
|
|
149
159
|
if (!isProven) {
|
|
150
160
|
rec.outcome = 'UNPROVEN';
|
|
151
161
|
rec.reason = proved?.proofEvidence?.reason || 'no proof-of-concept demonstrated this finding';
|
|
162
|
+
seal(rec, f, null);
|
|
152
163
|
results.push(rec); state.findings[key] = rec; _save(stateFile, state);
|
|
153
164
|
continue;
|
|
154
165
|
}
|
|
@@ -161,6 +172,7 @@ export async function runAutopilot({
|
|
|
161
172
|
if (!patch || !patch.patch) {
|
|
162
173
|
rec.outcome = 'NO_FIX';
|
|
163
174
|
rec.reason = 'no patch was synthesised';
|
|
175
|
+
seal(rec, f, null);
|
|
164
176
|
results.push(rec); state.findings[key] = rec; _save(stateFile, state);
|
|
165
177
|
continue;
|
|
166
178
|
}
|
|
@@ -171,6 +183,7 @@ export async function runAutopilot({
|
|
|
171
183
|
if (typeof stages.verifyFix !== 'function') {
|
|
172
184
|
rec.outcome = 'NEEDS_REVIEW';
|
|
173
185
|
rec.reason = 'no verifyFix stage supplied — a patch that cannot be re-verified is never applied';
|
|
186
|
+
seal(rec, f, patch);
|
|
174
187
|
results.push(rec); state.findings[key] = rec; _save(stateFile, state);
|
|
175
188
|
continue;
|
|
176
189
|
}
|
|
@@ -194,6 +207,7 @@ export async function runAutopilot({
|
|
|
194
207
|
: (!rec.testsPass ? 'the project test suite fails with this patch'
|
|
195
208
|
: (v?.reason || 'the patch did not re-verify'));
|
|
196
209
|
}
|
|
210
|
+
seal(rec, f, patch);
|
|
197
211
|
results.push(rec);
|
|
198
212
|
state.findings[key] = rec;
|
|
199
213
|
_save(stateFile, state);
|
|
@@ -203,6 +217,19 @@ export async function runAutopilot({
|
|
|
203
217
|
return { ok: true, results, skipped, outOfScope, capped, summary: summarizeAutopilot(results, outOfScope, capped) };
|
|
204
218
|
}
|
|
205
219
|
|
|
220
|
+
/**
|
|
221
|
+
* X-206: the autopilot response as it is serialized for a caller. Every field of every result is kept (the native `outcome`
|
|
222
|
+
* vocabulary and its meaning are unchanged); a result that carries a verification record additionally gets `verificationView`, the
|
|
223
|
+
* shared projection every other interface shows for the same finding. Results without a record are returned as they were.
|
|
224
|
+
*/
|
|
225
|
+
export function serializeAutopilotResult(res) {
|
|
226
|
+
if (!res || !Array.isArray(res.results)) return res;
|
|
227
|
+
return {
|
|
228
|
+
...res,
|
|
229
|
+
results: res.results.map((r) => (r && r.verificationRecord ? { ...r, ...verificationFields(r.verificationRecord, { replay: r.verificationReplay }) } : r)),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
206
233
|
export function summarizeAutopilot(results, outOfScope = 0, capped = 0) {
|
|
207
234
|
const byOutcome = Object.fromEntries(OUTCOMES.map(o => [o, 0]));
|
|
208
235
|
for (const r of results) if (r.outcome in byOutcome) byOutcome[r.outcome]++;
|
|
@@ -734,8 +734,12 @@ function buildNarrative(finding, signals) {
|
|
|
734
734
|
` (controls reducing cost: ${[...signals.controls].join(', ')})` : '';
|
|
735
735
|
const line = finding.line || finding.source?.line || finding.sink?.line || 0;
|
|
736
736
|
|
|
737
|
+
// A dependency advisory (Hackage, Nix) carries no `vuln` text, and `vuln` also feeds finding identity, so it is not invented there: the
|
|
738
|
+
// narrative builds its own label from what the finding does state, and leaves out a location clause it has no location for.
|
|
739
|
+
const label = finding.vuln || finding.title || advisoryLabel(finding);
|
|
740
|
+
const where = finding.file ? ` on \`${finding.file}:${line}\`` : '';
|
|
737
741
|
const narrative =
|
|
738
|
-
`${
|
|
742
|
+
`${label}${where} ` +
|
|
739
743
|
`could ${dataAtRisk.includes('rce') ? 'lead to' : 'expose'} ${dataPhrase}. ` +
|
|
740
744
|
`Context: ${indProfile.label} / ${jurStr}${controlsStr}. ` +
|
|
741
745
|
`Estimated cost: best ${fmtMoney(total.low)} · likely ${fmtMoney(total.likely)} · worst ${fmtMoney(total.high)}. ` +
|
|
@@ -778,6 +782,16 @@ function buildNarrative(finding, signals) {
|
|
|
778
782
|
};
|
|
779
783
|
}
|
|
780
784
|
|
|
785
|
+
/** A truthful label for a finding with no `vuln` or `title`: the advisory and package it names, else a plain "Finding". */
|
|
786
|
+
function advisoryLabel(f) {
|
|
787
|
+
const id = f.osvId || f.advisory || (Array.isArray(f.ids) && f.ids[0]) || null;
|
|
788
|
+
const pkg = f.name ? `${f.name}${f.version ? ` ${f.version}` : ''}` : null;
|
|
789
|
+
if (id && pkg) return `Advisory ${id} in ${pkg}`;
|
|
790
|
+
if (id) return `Advisory ${id}`;
|
|
791
|
+
if (pkg) return `Vulnerable dependency ${pkg}`;
|
|
792
|
+
return 'Finding';
|
|
793
|
+
}
|
|
794
|
+
|
|
781
795
|
function describeDataAtRisk(dataAtRisk, userCount) {
|
|
782
796
|
const n = userCount.toLocaleString();
|
|
783
797
|
if (dataAtRisk.includes('rce')) return 'full server compromise (remote code execution)';
|
|
@@ -47,6 +47,61 @@ import * as crypto from 'node:crypto';
|
|
|
47
47
|
|
|
48
48
|
export const BUNDLE_SCHEMA = 'agentic-security/finding-evidence@1';
|
|
49
49
|
|
|
50
|
+
// ---- issuer and trust basis (CORE-003.AC03) --------------------------------
|
|
51
|
+
//
|
|
52
|
+
// A signature says WHO signed and nothing about whether anyone independent
|
|
53
|
+
// vouches for them. This install generates its own key, so every bundle it
|
|
54
|
+
// signs is SELF-ISSUED. The `issuance` block says so inside the signed bytes,
|
|
55
|
+
// so a summariser cannot drop it, and the verifier reports it back. The only
|
|
56
|
+
// trust basis this build can verify is the local one; a third-party basis needs
|
|
57
|
+
// a trust-root policy that does not exist here, so an unknown basis is
|
|
58
|
+
// REJECTED rather than being displayed as if it were certification.
|
|
59
|
+
//
|
|
60
|
+
// Backward compatible: `issuance` is absent from bundles signed before this
|
|
61
|
+
// field existed. Those still verify exactly as before and are reported as
|
|
62
|
+
// having NO DECLARED trust basis (not as trusted, not as invalid).
|
|
63
|
+
export const TRUST_BASES = Object.freeze({
|
|
64
|
+
'self-issued-local-key': Object.freeze({
|
|
65
|
+
independentlyCertified: false,
|
|
66
|
+
statement:
|
|
67
|
+
'Signed by a key this install generated for itself. It proves the bundle is unmodified since ' +
|
|
68
|
+
'this key signed it. It is NOT independent third-party certification: whoever controls this ' +
|
|
69
|
+
'install controls the key.',
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** SHA-256 of the SPKI DER of a public key: the stable identity of an issuer. */
|
|
74
|
+
export function keyFingerprint(publicKeyPem) {
|
|
75
|
+
const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
|
|
76
|
+
return crypto.createHash('sha256').update(der).digest('hex');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Issuance block for a locally generated key. */
|
|
80
|
+
function buildLocalIssuance(publicKeyPem) {
|
|
81
|
+
const fp = keyFingerprint(publicKeyPem);
|
|
82
|
+
return {
|
|
83
|
+
issuer: { id: `local-install:${fp.slice(0, 16)}`, kind: 'local-install', keyFingerprint: fp },
|
|
84
|
+
trustBasis: 'self-issued-local-key',
|
|
85
|
+
independentlyCertified: false,
|
|
86
|
+
statement: TRUST_BASES['self-issued-local-key'].statement,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Group/world access to key material is a defect, reported by path. */
|
|
91
|
+
export function keyPermissionIssues(dir = keyDir()) {
|
|
92
|
+
const p = keyPaths(dir);
|
|
93
|
+
const issues = [];
|
|
94
|
+
try {
|
|
95
|
+
const d = fs.statSync(dir).mode & 0o077;
|
|
96
|
+
if (d) issues.push(`${dir} is accessible to group/other (mode ${(fs.statSync(dir).mode & 0o777).toString(8)})`);
|
|
97
|
+
} catch { /* absent: nothing to report */ }
|
|
98
|
+
try {
|
|
99
|
+
const m = fs.statSync(p.privateKey).mode & 0o777;
|
|
100
|
+
if (m & 0o077) issues.push(`${p.privateKey} is accessible to group/other (mode ${m.toString(8)})`);
|
|
101
|
+
} catch { /* absent */ }
|
|
102
|
+
return issues;
|
|
103
|
+
}
|
|
104
|
+
|
|
50
105
|
/** Where the Ed25519 signing key lives. Mirrors integrity.js's key handling. */
|
|
51
106
|
function keyDir() {
|
|
52
107
|
const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
@@ -142,6 +197,9 @@ export function canonicalBytes(bundle) {
|
|
|
142
197
|
engine: bundle.engine,
|
|
143
198
|
proves: bundle.proves,
|
|
144
199
|
doesNotProve: bundle.doesNotProve,
|
|
200
|
+
// Only when present, so the canonical bytes (and signatures) of every
|
|
201
|
+
// bundle signed before this field existed are unchanged.
|
|
202
|
+
...(bundle.issuance !== undefined ? { issuance: bundle.issuance } : {}),
|
|
145
203
|
};
|
|
146
204
|
return Buffer.from(canonicalJson(signed), 'utf8');
|
|
147
205
|
}
|
|
@@ -226,11 +284,17 @@ export function buildEvidenceBundle(finding, { engineVersion, rulesetVersion, bu
|
|
|
226
284
|
};
|
|
227
285
|
}
|
|
228
286
|
|
|
229
|
-
/**
|
|
287
|
+
/**
|
|
288
|
+
* Sign a bundle. Returns a new object; the input is not mutated. The issuer and
|
|
289
|
+
* trust basis are derived from the signing key and signed with the bundle; a
|
|
290
|
+
* caller cannot supply a stronger basis than the one this build can verify.
|
|
291
|
+
*/
|
|
230
292
|
export function signEvidenceBundle(bundle, privateKeyPem) {
|
|
231
|
-
const
|
|
293
|
+
const publicKeyPem = crypto.createPublicKey(privateKeyPem).export({ type: 'spki', format: 'pem' });
|
|
294
|
+
const withIssuance = { ...bundle, issuance: buildLocalIssuance(publicKeyPem) };
|
|
295
|
+
const sig = crypto.sign(null, canonicalBytes(withIssuance), privateKeyPem);
|
|
232
296
|
return {
|
|
233
|
-
...
|
|
297
|
+
...withIssuance,
|
|
234
298
|
signature: {
|
|
235
299
|
algorithm: 'ed25519',
|
|
236
300
|
canonicalisation: BUNDLE_SCHEMA,
|
|
@@ -256,7 +320,7 @@ export function signEvidenceBundle(bundle, privateKeyPem) {
|
|
|
256
320
|
// what buildEvidenceBundle+signEvidenceBundle actually produce, or a
|
|
257
321
|
// legitimate bundle would start failing verification.
|
|
258
322
|
const BUNDLE_TOP_LEVEL_KEYS = new Set([
|
|
259
|
-
'schema', 'finding', 'evidence', 'engine', 'proves', 'doesNotProve', 'signature',
|
|
323
|
+
'schema', 'finding', 'evidence', 'engine', 'proves', 'doesNotProve', 'issuance', 'signature',
|
|
260
324
|
]);
|
|
261
325
|
|
|
262
326
|
export function verifyEvidenceBundle(bundle, publicKeyPem) {
|
|
@@ -276,7 +340,29 @@ export function verifyEvidenceBundle(bundle, publicKeyPem) {
|
|
|
276
340
|
} catch (e) {
|
|
277
341
|
return { ok: false, reason: `verification error: ${e.message}` };
|
|
278
342
|
}
|
|
279
|
-
return ok
|
|
280
|
-
|
|
281
|
-
|
|
343
|
+
if (!ok) return { ok: false, reason: 'signature does not match the bundle contents — it was modified after signing' };
|
|
344
|
+
|
|
345
|
+
// Trust basis. Checked AFTER the signature so the issuance block is already
|
|
346
|
+
// known to be unmodified.
|
|
347
|
+
if (bundle.issuance === undefined) {
|
|
348
|
+
return {
|
|
349
|
+
ok: true, reason: null, trustBasis: 'none-declared', independentlyCertified: false, issuer: null,
|
|
350
|
+
trustNote: 'This bundle predates issuer declarations: no trust basis is declared. Treat it as unattested provenance, not as certification.',
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const iss = bundle.issuance;
|
|
354
|
+
const basis = iss && typeof iss === 'object' ? TRUST_BASES[iss.trustBasis] : undefined;
|
|
355
|
+
if (!basis) return { ok: false, reason: `unsupported trust basis: ${JSON.stringify(iss?.trustBasis ?? null)}` };
|
|
356
|
+
if (iss.independentlyCertified !== false) {
|
|
357
|
+
return { ok: false, reason: 'a self-issued bundle may not claim independent certification' };
|
|
358
|
+
}
|
|
359
|
+
let fp;
|
|
360
|
+
try { fp = keyFingerprint(publicKeyPem); } catch (e) { return { ok: false, reason: `verification error: ${e.message}` }; }
|
|
361
|
+
if (iss.issuer?.keyFingerprint !== fp) {
|
|
362
|
+
return { ok: false, reason: 'the declared issuer key does not match the key that verified the signature' };
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
ok: true, reason: null, trustBasis: iss.trustBasis, independentlyCertified: false,
|
|
366
|
+
issuer: iss.issuer, trustNote: basis.statement,
|
|
367
|
+
};
|
|
282
368
|
}
|
|
@@ -8,6 +8,7 @@ import os from 'node:os';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { runConfined, sandboxAvailable, detectBackend } from '../sandbox/index.js';
|
|
10
10
|
import { attachProofTier, proofTierOf } from './proof-tier.js';
|
|
11
|
+
import { emitVerification } from './verification/emit.js';
|
|
11
12
|
|
|
12
13
|
const PROOF_MARKER = 'PROVEN';
|
|
13
14
|
|
|
@@ -75,7 +76,16 @@ export const DEFAULT_PROOF_TIMEOUT_MS =
|
|
|
75
76
|
? Number(process.env.AGENTIC_SECURITY_PROOF_TIMEOUT_MS)
|
|
76
77
|
: 45000;
|
|
77
78
|
|
|
78
|
-
|
|
79
|
+
// X-201: the proof result also carries the one verification record, ADDITIVELY (`proofTier` and `proofEvidence` are
|
|
80
|
+
// unchanged). A PoC run under this older sandbox path is not a trusted runner, so the record says `inconclusive` for a
|
|
81
|
+
// run that demonstrated the effect and for one that did not; `confirmed` takes a trusted oracle adapter.
|
|
82
|
+
export async function proveFinding(finding, opts = {}) {
|
|
83
|
+
const proved = await _proveFinding(finding, opts);
|
|
84
|
+
const emitted = emitVerification('execution-proof', proved, { finding: proved, commit: opts.commit ?? null });
|
|
85
|
+
return emitted.ok ? { ...proved, verificationRecord: emitted.record } : proved;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function _proveFinding(finding, { timeoutMs = DEFAULT_PROOF_TIMEOUT_MS, force, files } = {}) {
|
|
79
89
|
const poc = finding?.poc;
|
|
80
90
|
if (!poc?.code) {
|
|
81
91
|
return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: 'no proof-of-concept attached' }));
|