@clear-capabilities/agentic-security-scanner 0.161.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 +20 -0
- package/bin/agentic-security.js +11 -2
- package/dist/4113.index.js +59 -8
- package/dist/5613.index.js +500 -0
- package/dist/6239.index.js +469 -0
- package/dist/7838.index.js +38 -2
- package/dist/8798.index.js +1 -1
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +36 -22
- package/dist/{1905.index.js → 9999.index.js} +265 -8
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +9 -4
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +28 -0
- package/src/discovery/memory.js +7 -5
- package/src/fix/apply-fix-service.js +33 -3
- package/src/mcp/CLAUDE.md +9 -1
- package/src/mcp/tools.js +17 -4
- 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/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/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
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Non-taint vulnerability classes and the rules for judging them (X-205).
|
|
2
|
+
//
|
|
3
|
+
// A taint engine answers "does attacker data reach a dangerous call". Several
|
|
4
|
+
// defect classes have no such flow: a missing tenant check, a privileged action
|
|
5
|
+
// open to a lower role, a workflow step that can be skipped, a request whose
|
|
6
|
+
// effect repeats when it is delivered again, an unbounded work parameter. A
|
|
7
|
+
// source-to-sink analysis finding nothing says NOTHING about them, so this module
|
|
8
|
+
// states three things, each with a test:
|
|
9
|
+
//
|
|
10
|
+
// 1. WHICH classes are supported (`SCENARIO_CLASSES`), and for each one the
|
|
11
|
+
// adapter that executes it, what the requester must supply, the fixtures that
|
|
12
|
+
// prove it works in both directions, and the limitations a reader must see.
|
|
13
|
+
// Where an existing adapter already covers a class it is REUSED, and the
|
|
14
|
+
// entry says so (`adapterReuse`); only replay/idempotency needed a new one.
|
|
15
|
+
// 2. That taint absence NEVER refutes one of these hypotheses
|
|
16
|
+
// (`judgeNonTaintHypothesis`): without an executed oracle of the class's own
|
|
17
|
+
// adapter the outcome is `not-run`, never `refuted`.
|
|
18
|
+
// 3. Which results may count as a trusted negative (`trustedNegativeDenominator`):
|
|
19
|
+
// only a decided result from an executed oracle with proven preconditions.
|
|
20
|
+
// Unsupported, not-run, inconclusive, error, static-only and model-only results
|
|
21
|
+
// are listed explicitly and excluded; they cannot be counted as "checked and
|
|
22
|
+
// clean".
|
|
23
|
+
//
|
|
24
|
+
// This module is pure data and pure functions: it imports no adapter and runs
|
|
25
|
+
// nothing, so `oracle.js` and `registry.js` can both depend on it.
|
|
26
|
+
|
|
27
|
+
export const NON_TAINT_SCHEMA = 'agentic-security/non-taint-classes';
|
|
28
|
+
|
|
29
|
+
const FIXTURE_ROOT = 'test/fixtures/non-taint';
|
|
30
|
+
|
|
31
|
+
export const SCENARIO_CLASSES = Object.freeze([
|
|
32
|
+
Object.freeze({
|
|
33
|
+
id: 'tenant-authorization',
|
|
34
|
+
title: 'Tenant authorization',
|
|
35
|
+
adapterId: 'authorization-decision', adapterReuse: true,
|
|
36
|
+
claim: 'an actor from one tenant is allowed to read or change another tenant\'s resource',
|
|
37
|
+
cwes: ['CWE-639', 'CWE-566', 'CWE-863', 'CWE-862', 'CWE-285', 'CWE-284'],
|
|
38
|
+
families: ['idor', 'authz', 'missing-authz', 'broken-access-control', 'tenant-isolation'],
|
|
39
|
+
requires: ['an exported decision function taking the actor and the resource (or ids that resolve to them)', 'one control case the function must allow (the owner) and one attack case it must deny (another tenant)'],
|
|
40
|
+
limitations: ['asserts the decision function in isolation; a route or middleware that enforces the check elsewhere is not exercised', 'the scenario names which actors should be denied: the oracle does not infer tenancy rules', 'row-level checks inside a database query are not observed'],
|
|
41
|
+
fixtures: { dir: `${FIXTURE_ROOT}/tenant-authorization`, positive: 'positive', negative: 'negative', scenario: 'scenario.json' },
|
|
42
|
+
}),
|
|
43
|
+
Object.freeze({
|
|
44
|
+
id: 'privileged-action',
|
|
45
|
+
title: 'Privileged action',
|
|
46
|
+
adapterId: 'authorization-decision', adapterReuse: true,
|
|
47
|
+
claim: 'a lower-privileged actor is allowed to perform an action reserved for a higher role',
|
|
48
|
+
cwes: ['CWE-269', 'CWE-250', 'CWE-266', 'CWE-267'],
|
|
49
|
+
families: ['privilege-escalation', 'privileged-action', 'missing-role-check'],
|
|
50
|
+
requires: ['an exported decision function taking the actor and the action', 'one control case with the privileged role (must be allowed) and one attack case with an ordinary role (must be denied)'],
|
|
51
|
+
limitations: ['asserts the decision function in isolation; role checks enforced only by a gateway or framework guard are not exercised', 'a thrown error is not read as a denial', 'role and permission grants stored outside the function are not observed'],
|
|
52
|
+
fixtures: { dir: `${FIXTURE_ROOT}/privileged-action`, positive: 'positive', negative: 'negative', scenario: 'scenario.json' },
|
|
53
|
+
}),
|
|
54
|
+
Object.freeze({
|
|
55
|
+
id: 'workflow-order',
|
|
56
|
+
title: 'Workflow order',
|
|
57
|
+
adapterId: 'state-transition', adapterReuse: true,
|
|
58
|
+
claim: 'a step that must follow another can be performed first (for example a refund before approval)',
|
|
59
|
+
cwes: ['CWE-841', 'CWE-840', 'CWE-696'],
|
|
60
|
+
families: ['workflow', 'business-logic', 'state-machine', 'workflow-order'],
|
|
61
|
+
requires: ['an exported factory returning a machine with `send(event)` and a `state`', 'a legal event sequence with its expected final state, an attack sequence and the forbidden state'],
|
|
62
|
+
limitations: ['covers one factory and finite event lists; it is not a model checker', 'durable effects outside the machine object (a database row, a queued job) are not observed', 'the forbidden state is stated by the scenario: business rules are not inferred'],
|
|
63
|
+
fixtures: { dir: `${FIXTURE_ROOT}/workflow-order`, positive: 'positive', negative: 'negative', scenario: 'scenario.json' },
|
|
64
|
+
}),
|
|
65
|
+
Object.freeze({
|
|
66
|
+
id: 'replay-idempotency',
|
|
67
|
+
title: 'Replay and idempotency',
|
|
68
|
+
adapterId: 'replay-idempotency', adapterReuse: false,
|
|
69
|
+
claim: 'delivering the same request again repeats its side effect (a double charge, a double credit)',
|
|
70
|
+
cwes: ['CWE-294', 'CWE-837', 'CWE-841'],
|
|
71
|
+
families: ['replay', 'idempotency', 'replay-attack', 'duplicate-submission'],
|
|
72
|
+
requires: ['an exported factory that receives a recording stand-in for the effect and returns a request handler', 'a request, a distinct request and a delivery count'],
|
|
73
|
+
limitations: ['deliveries are sequential: a race between concurrent deliveries is not exercised', 'deduplication kept in an external store is not observed, only the handler the factory returns', 'code that imports its effect directly instead of receiving it is not covered'],
|
|
74
|
+
fixtures: { dir: `${FIXTURE_ROOT}/replay-idempotency`, positive: 'positive', negative: 'negative', scenario: 'scenario.json' },
|
|
75
|
+
}),
|
|
76
|
+
Object.freeze({
|
|
77
|
+
id: 'resource-exhaustion',
|
|
78
|
+
title: 'Bounded resource exhaustion',
|
|
79
|
+
adapterId: 'parser-resource', adapterReuse: true,
|
|
80
|
+
claim: 'a bounded hostile parameter makes the code exceed its time or output budget (an uncapped page size, an unbounded loop)',
|
|
81
|
+
cwes: ['CWE-400', 'CWE-770', 'CWE-789', 'CWE-405', 'CWE-834'],
|
|
82
|
+
families: ['resource-exhaustion', 'dos', 'denial-of-service', 'unbounded-loop'],
|
|
83
|
+
requires: ['an exported function taking one string', 'a benign input it must finish and a hostile input described as a unit repeated at most 65536 times'],
|
|
84
|
+
limitations: ['the hostile input is bounded and the run is cut at the supervisor deadline: this shows a budget was exceeded, not how large the blow-up is', 'memory is not measured', 'distributed or network-level exhaustion, and exhaustion that needs a populated datastore, are not exercised'],
|
|
85
|
+
fixtures: { dir: `${FIXTURE_ROOT}/resource-exhaustion`, positive: 'positive', negative: 'negative', scenario: 'scenario.json' },
|
|
86
|
+
}),
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
// Non-taint classes this build recognises and does NOT execute. A finding that maps here is `unsupported`,
|
|
90
|
+
// stated by name, and never counted as checked.
|
|
91
|
+
export const UNSUPPORTED_NON_TAINT = Object.freeze([
|
|
92
|
+
Object.freeze({ id: 'concurrent-race', cwes: ['CWE-362', 'CWE-367'], reason: 'a race needs concurrent scheduling; the adapters deliver sequentially, so no verdict about a race can be formed' }),
|
|
93
|
+
Object.freeze({ id: 'cross-site-request-forgery', cwes: ['CWE-352'], reason: 'needs a browser and a session; no adapter exercises one' }),
|
|
94
|
+
Object.freeze({ id: 'authentication-throttling', cwes: ['CWE-307', 'CWE-799'], reason: 'needs a stateful attempt counter over time and a populated store; the adapters bound a single input, not a campaign' }),
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
const BY_ID = new Map(SCENARIO_CLASSES.map((c) => [c.id, c]));
|
|
98
|
+
|
|
99
|
+
export function scenarioClassesForAdapter(adapterId) { return SCENARIO_CLASSES.filter((c) => c.adapterId === adapterId); }
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The lines a user reads about one class: what it asserts, what the requester must supply (prerequisites), the platform
|
|
103
|
+
* statement and every limitation. A report that shows a class shows these with it.
|
|
104
|
+
* @param {object} cls a SCENARIO_CLASSES entry
|
|
105
|
+
* @param {object} [adapter] the executing adapter (prerequisites and platform statements are taken from it)
|
|
106
|
+
*/
|
|
107
|
+
export function scenarioClassReportLines(cls, adapter = null) {
|
|
108
|
+
const lines = [`${cls.title} (${cls.id}): ${cls.claim}.`];
|
|
109
|
+
lines.push(` oracle: ${cls.adapterId}${cls.adapterReuse ? ' (an existing adapter, reused for this class)' : ' (added for this class)'}`);
|
|
110
|
+
lines.push(` prerequisites: ${[...cls.requires, ...(adapter ? adapter.prerequisites.map((p) => `host: ${p}`) : [])].join('; ')}`);
|
|
111
|
+
if (adapter) lines.push(` platforms: macOS ${adapter.platforms.darwin.status}, Linux ${adapter.platforms.linux.status}, Windows ${adapter.platforms.win32.status}`);
|
|
112
|
+
for (const l of cls.limitations) lines.push(` limitation: ${l}`);
|
|
113
|
+
lines.push(' not established: a clean result covers only the declared scenario; it is not proof the class is absent from the code.');
|
|
114
|
+
return lines;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ------------------------------------------------------------------ classification
|
|
118
|
+
|
|
119
|
+
const cweOf = (f) => String(f?.cwe || '').toUpperCase().replace(/^CWE[-_ ]?/, 'CWE-');
|
|
120
|
+
const familyOf = (f) => String(f?.family || '').toLowerCase();
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Which non-taint class, supported or not, does this finding belong to? `null` when it is not a recognised non-taint
|
|
124
|
+
* hypothesis (then nothing in this module applies). An explicit `finding.scenarioClass` wins over CWE and family.
|
|
125
|
+
* @returns {null | { classId: string, supported: boolean, title: string, reason?: string }}
|
|
126
|
+
*/
|
|
127
|
+
export function nonTaintClassOf(finding) {
|
|
128
|
+
if (!finding || typeof finding !== 'object') return null;
|
|
129
|
+
if (typeof finding.scenarioClass === 'string') {
|
|
130
|
+
const c = BY_ID.get(finding.scenarioClass);
|
|
131
|
+
if (c) return { classId: c.id, supported: true, title: c.title };
|
|
132
|
+
}
|
|
133
|
+
const cwe = cweOf(finding);
|
|
134
|
+
const fam = familyOf(finding);
|
|
135
|
+
for (const u of UNSUPPORTED_NON_TAINT) {
|
|
136
|
+
if (u.cwes.includes(cwe) || fam === u.id) return { classId: u.id, supported: false, title: u.id.replace(/-/g, ' '), reason: u.reason };
|
|
137
|
+
}
|
|
138
|
+
for (const c of SCENARIO_CLASSES) {
|
|
139
|
+
if (c.cwes.includes(cwe) || c.families.includes(fam)) return { classId: c.id, supported: true, title: c.title };
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ------------------------------------------------------------------ judging a non-taint hypothesis
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Judge a non-taint hypothesis from what is actually known about it.
|
|
148
|
+
*
|
|
149
|
+
* The rule: absence of a taint flow is not evidence about these classes, so it is never an input to a refutation. An
|
|
150
|
+
* outcome of `refuted` needs an EXECUTED oracle result from the class's own adapter with proven preconditions; anything
|
|
151
|
+
* else is `not-run` (a supported class nobody ran), `unsupported` (a class this build cannot execute) or the oracle's own
|
|
152
|
+
* `confirmed`/`inconclusive`/`error`.
|
|
153
|
+
*
|
|
154
|
+
* @param {object} args
|
|
155
|
+
* @param {object} args.finding
|
|
156
|
+
* @param {object} [args.taint] static taint evidence, for example `{ clean: true }`; recorded, never used to refute
|
|
157
|
+
* @param {object} [args.oracleResult] `{ outcome, oracleId, preconditionsValid }` from a run of an oracle adapter
|
|
158
|
+
* @returns {{ nonTaint: boolean, classId?: string, supported?: boolean, outcome?: string, reason?: string, taintAbsenceUsed: false }}
|
|
159
|
+
*/
|
|
160
|
+
export function judgeNonTaintHypothesis({ finding, taint = null, oracleResult = null } = {}) {
|
|
161
|
+
const cls = nonTaintClassOf(finding);
|
|
162
|
+
if (!cls) return { nonTaint: false, taintAbsenceUsed: false };
|
|
163
|
+
const taintNote = taint && taint.clean === true
|
|
164
|
+
? ' No source-to-sink flow was found, and that says nothing about this class: it is not a refutation.'
|
|
165
|
+
: '';
|
|
166
|
+
if (!cls.supported) {
|
|
167
|
+
return { nonTaint: true, classId: cls.classId, supported: false, outcome: 'unsupported', taintAbsenceUsed: false,
|
|
168
|
+
reason: `${cls.title} is a non-taint class this build does not execute: ${cls.reason}.${taintNote}` };
|
|
169
|
+
}
|
|
170
|
+
const own = BY_ID.get(cls.classId);
|
|
171
|
+
const r = oracleResult && typeof oracleResult === 'object' ? oracleResult : null;
|
|
172
|
+
const decisive = r && r.oracleId === own.adapterId && ['confirmed', 'refuted', 'inconclusive', 'error'].includes(r.outcome);
|
|
173
|
+
if (!decisive) {
|
|
174
|
+
return { nonTaint: true, classId: cls.classId, supported: true, outcome: 'not-run', taintAbsenceUsed: false,
|
|
175
|
+
reason: `no executed ${own.adapterId} oracle result exists for this ${cls.title.toLowerCase()} hypothesis, so it stays unrefuted.${taintNote}` };
|
|
176
|
+
}
|
|
177
|
+
if (r.outcome === 'refuted' && r.preconditionsValid !== true) {
|
|
178
|
+
return { nonTaint: true, classId: cls.classId, supported: true, outcome: 'inconclusive', taintAbsenceUsed: false,
|
|
179
|
+
reason: `the ${own.adapterId} oracle did not show its preconditions held, so its negative result is not a refutation.${taintNote}` };
|
|
180
|
+
}
|
|
181
|
+
return { nonTaint: true, classId: cls.classId, supported: true, outcome: r.outcome, taintAbsenceUsed: false,
|
|
182
|
+
reason: `decided by the executed ${own.adapterId} oracle.${taintNote}` };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ------------------------------------------------------------------ the trusted-negative denominator
|
|
186
|
+
|
|
187
|
+
const ORACLE_IDS = new Set(SCENARIO_CLASSES.map((c) => c.adapterId));
|
|
188
|
+
|
|
189
|
+
function whyExcluded(rec) {
|
|
190
|
+
if (!rec || typeof rec !== 'object') return 'malformed';
|
|
191
|
+
if (rec.outcome === 'unsupported') return 'unsupported';
|
|
192
|
+
if (rec.outcome === 'not-run') return 'not-run';
|
|
193
|
+
if (rec.outcome === 'inconclusive') return 'inconclusive';
|
|
194
|
+
if (rec.outcome === 'error') return 'error';
|
|
195
|
+
if (rec.outcome !== 'confirmed' && rec.outcome !== 'refuted') return 'not-decided';
|
|
196
|
+
if (!rec.oracle || rec.oracle.kind !== 'runtime-replay') return 'no-executed-oracle';
|
|
197
|
+
if (!ORACLE_IDS.has(rec.oracle.id)) return 'oracle-not-in-supported-class-manifest';
|
|
198
|
+
if (!rec.preconditions || rec.preconditions.valid !== true) return 'preconditions-not-proved';
|
|
199
|
+
if (!Array.isArray(rec.evidence) || !rec.evidence.some((e) => e && e.kind === 'trusted-runtime-proof')) return 'no-trusted-runtime-proof';
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The trusted-negative denominator over verification records: the decided results (`confirmed` or `refuted`) of an
|
|
205
|
+
* executed oracle of an advertised class with proven preconditions and trusted runtime proof. Everything else is listed
|
|
206
|
+
* with the reason it is excluded; an unsupported or unrun case can never be counted as a negative.
|
|
207
|
+
*/
|
|
208
|
+
export function trustedNegativeDenominator(records) {
|
|
209
|
+
const list = Array.isArray(records) ? records : [];
|
|
210
|
+
let trustedNegatives = 0;
|
|
211
|
+
let trustedPositives = 0;
|
|
212
|
+
const excluded = [];
|
|
213
|
+
for (const rec of list) {
|
|
214
|
+
const why = whyExcluded(rec);
|
|
215
|
+
if (why) { excluded.push({ id: rec && rec.id ? String(rec.id) : null, outcome: rec && rec.outcome ? String(rec.outcome) : null, reason: why }); continue; }
|
|
216
|
+
if (rec.outcome === 'refuted') trustedNegatives++; else trustedPositives++;
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
denominator: trustedNegatives + trustedPositives, trustedNegatives, trustedPositives,
|
|
220
|
+
excluded, unsupported: excluded.filter((e) => e.reason === 'unsupported'),
|
|
221
|
+
total: list.length,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Replay manifests and environment identity (X-203).
|
|
2
|
+
//
|
|
3
|
+
// Extends the existing proof-of-concept sandbox path (a payload run under the
|
|
4
|
+
// confinement backend, judged by an observed effect) with an identity for the run
|
|
5
|
+
// and a way to repeat it. It is NOT a second sandbox or a second runner: a replay
|
|
6
|
+
// is an oracle run (`oracles/oracle.js`), which goes through `runInBoundary` and
|
|
7
|
+
// therefore through the supervised process tree, output cap and cleanup of
|
|
8
|
+
// CORE-003. What this module adds is the pinned, validated description of the run.
|
|
9
|
+
//
|
|
10
|
+
// A manifest pins: the repository commit, the patch hash, the fixture hash, the
|
|
11
|
+
// toolchain (and container digest when there is one), the oracle id, version and
|
|
12
|
+
// logic digest, the inputs and the budgets. Replaying it on the same supported
|
|
13
|
+
// environment reproduces the verdict, and the identical verification record id.
|
|
14
|
+
//
|
|
15
|
+
// Honesty rules, each with a test:
|
|
16
|
+
// - A manifest is REJECTED, and nothing executes, when a content hash does not
|
|
17
|
+
// match the content, when the oracle changed since the manifest was made, when
|
|
18
|
+
// its scope asks for more than the oracle's declared scope (network, another
|
|
19
|
+
// oracle, a write outside the workspace) or when a budget exceeds the oracle's
|
|
20
|
+
// ceiling. The manifest is closed-world, so a field such as a model verdict
|
|
21
|
+
// cannot ride along.
|
|
22
|
+
// - A missing toolchain, container image or container runtime, or a disabled
|
|
23
|
+
// feature, is a typed PREREQUISITE and the attempt is resumable. Nothing is
|
|
24
|
+
// substituted for the execution that did not happen: the outcome is `not-run`
|
|
25
|
+
// (or `unsupported` when this build can never meet it), with no evidence.
|
|
26
|
+
// - This build never fetches anything. A prerequisite whose acquisition would
|
|
27
|
+
// need the network is reported as such, with the network denied.
|
|
28
|
+
// - Container execution is not implemented. A manifest pinning a container
|
|
29
|
+
// digest is therefore `unsupported` here, never run outside the container it
|
|
30
|
+
// names.
|
|
31
|
+
// - Toolchain identity is exact (runtime version, platform, architecture). A
|
|
32
|
+
// different patch release is a different environment, reported as an unmet
|
|
33
|
+
// prerequisite rather than silently tolerated.
|
|
34
|
+
import { digestOf, semanticId } from '../assurance/identity.js';
|
|
35
|
+
import { SCHEMA_VERSION, isCommit, isDigest, isPlainObject } from '../assurance/schema-kit.js';
|
|
36
|
+
import { detectBackend } from '../../sandbox/capabilities.js';
|
|
37
|
+
import { runOracle, recordWithoutExecution, isSafeRelativePath, FEATURE_ID } from '../oracles/oracle.js';
|
|
38
|
+
import * as registry from '../oracles/registry.js';
|
|
39
|
+
|
|
40
|
+
export const REPLAY_SCHEMA = 'agentic-security/replay-manifest';
|
|
41
|
+
export const ATTEMPT_SCHEMA = 'agentic-security/replay-attempt';
|
|
42
|
+
export const MAX_ATTEMPTS = 10;
|
|
43
|
+
|
|
44
|
+
const ALLOWED = ['schema', 'schemaVersion', 'id', 'hypothesisId', 'repository', 'patch', 'fixture', 'toolchain', 'oracle', 'entry', 'inputs', 'inputsDigest', 'budgets', 'scope', 'expected'];
|
|
45
|
+
const REQUIRED = ['schema', 'schemaVersion', 'id', 'hypothesisId', 'repository', 'patch', 'fixture', 'toolchain', 'oracle', 'entry', 'inputs', 'inputsDigest', 'budgets', 'scope'];
|
|
46
|
+
const ID_FIELDS = ALLOWED.filter((k) => k !== 'id');
|
|
47
|
+
const REPLAY_BUDGET_KEYS = ['timeoutMs', 'graceMs', 'maxOutputBytes'];
|
|
48
|
+
const EXPECTED_OUTCOMES = ['confirmed', 'refuted', 'inconclusive'];
|
|
49
|
+
|
|
50
|
+
/** The pinned scope a replay may have: exactly the oracle it names, no network, workspace-only writes. */
|
|
51
|
+
export function allowedScope(oracleId) {
|
|
52
|
+
return { executes: [oracleId], network: false, writes: 'workspace-only' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Sanitized identity of the execution environment: facts about the toolchain, nothing about the host. */
|
|
56
|
+
export function environmentIdentity(overrides = {}) {
|
|
57
|
+
const base = {
|
|
58
|
+
runtime: 'node', version: process.versions.node, platform: process.platform, arch: process.arch,
|
|
59
|
+
containerDigest: null, backend: detectBackend(),
|
|
60
|
+
};
|
|
61
|
+
const env = { ...base, ...overrides };
|
|
62
|
+
return { ...env, toolchainDigest: digestOf({ runtime: env.runtime, version: env.version, platform: env.platform, arch: env.arch, containerDigest: env.containerDigest }) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build a manifest from the pieces of a verification. The oracle identity and the
|
|
67
|
+
* environment come from this build, not from the caller.
|
|
68
|
+
*/
|
|
69
|
+
export function createReplayManifest({ hypothesisId, commit, fixtureFiles, patchFiles = null, oracleId, entry, inputs, budgets = {}, expected, environment }) {
|
|
70
|
+
const oracle = registry.getOracle(oracleId);
|
|
71
|
+
if (!oracle) throw new Error(`unknown oracle '${oracleId}'`);
|
|
72
|
+
const env = environment || environmentIdentity();
|
|
73
|
+
const m = {
|
|
74
|
+
schema: REPLAY_SCHEMA, schemaVersion: SCHEMA_VERSION, hypothesisId,
|
|
75
|
+
repository: { commit },
|
|
76
|
+
patch: patchFiles && Object.keys(patchFiles).length ? { digest: digestOf(patchFiles) } : null,
|
|
77
|
+
fixture: { digest: digestOf(fixtureFiles) },
|
|
78
|
+
toolchain: { runtime: env.runtime, version: env.version, platform: env.platform, arch: env.arch, containerDigest: env.containerDigest ?? null },
|
|
79
|
+
oracle: { id: oracle.id, version: oracle.version, logicDigest: oracle.logicDigest },
|
|
80
|
+
entry, inputs, inputsDigest: digestOf(inputs),
|
|
81
|
+
budgets: { timeoutMs: oracle.budgets.timeoutMs, graceMs: oracle.budgets.graceMs, maxOutputBytes: oracle.budgets.maxOutputBytes, ...budgets },
|
|
82
|
+
scope: allowedScope(oracle.id),
|
|
83
|
+
};
|
|
84
|
+
if (expected) m.expected = expected;
|
|
85
|
+
m.id = manifestId(m);
|
|
86
|
+
return m;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function manifestId(m) { return semanticId('rpl', m, ID_FIELDS); }
|
|
90
|
+
|
|
91
|
+
function err(errors, code, path, message) { errors.push({ code, path, message }); }
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validate a manifest against the content it describes. Rejects, and the caller
|
|
95
|
+
* must not execute, on any error. Pure: reads no file and runs nothing.
|
|
96
|
+
*/
|
|
97
|
+
export function validateReplayManifest(m, { fixtureFiles, patchFiles = null } = {}) {
|
|
98
|
+
const errors = [];
|
|
99
|
+
if (!isPlainObject(m)) { err(errors, 'NOT_AN_OBJECT', '', 'manifest must be an object'); return { ok: false, errors }; }
|
|
100
|
+
if (m.schema !== REPLAY_SCHEMA) { err(errors, 'SCHEMA_MISMATCH', 'schema', `expected '${REPLAY_SCHEMA}'`); return { ok: false, errors }; }
|
|
101
|
+
if (typeof m.schemaVersion !== 'string' || m.schemaVersion.split('.')[0] !== '1') { err(errors, 'UNSUPPORTED_MAJOR', 'schemaVersion', 'only major version 1 is supported'); return { ok: false, errors }; }
|
|
102
|
+
for (const k of Object.keys(m)) if (!ALLOWED.includes(k)) err(errors, 'UNKNOWN_FIELD', k, `field '${k}' is not part of a replay manifest`);
|
|
103
|
+
for (const k of REQUIRED) if (m[k] === undefined) err(errors, 'MISSING_FIELD', k, `required field '${k}' is missing`);
|
|
104
|
+
if (errors.length) return { ok: false, errors };
|
|
105
|
+
|
|
106
|
+
if (typeof m.hypothesisId !== 'string' || !m.hypothesisId) err(errors, 'BAD_TYPE', 'hypothesisId', 'must be a non-empty string');
|
|
107
|
+
if (!isPlainObject(m.repository) || !isCommit(m.repository.commit)) err(errors, 'BAD_COMMIT', 'repository.commit', 'a replay pins an exact 40 or 64 character commit');
|
|
108
|
+
if (!isPlainObject(m.fixture) || !isDigest(m.fixture.digest)) err(errors, 'BAD_DIGEST', 'fixture.digest', 'must be sha256:<64 hex>');
|
|
109
|
+
else if (!isPlainObject(fixtureFiles) || digestOf(fixtureFiles) !== m.fixture.digest) err(errors, 'HASH_MISMATCH', 'fixture.digest', 'the fixture content does not match its pinned hash');
|
|
110
|
+
const havePatch = isPlainObject(patchFiles) && Object.keys(patchFiles).length > 0;
|
|
111
|
+
if (m.patch === null) {
|
|
112
|
+
if (havePatch) err(errors, 'HASH_MISMATCH', 'patch', 'patch content was supplied but the manifest pins no patch');
|
|
113
|
+
} else if (!isPlainObject(m.patch) || !isDigest(m.patch.digest)) err(errors, 'BAD_DIGEST', 'patch.digest', 'must be null or { digest: sha256:<64 hex> }');
|
|
114
|
+
else if (!havePatch || digestOf(patchFiles) !== m.patch.digest) err(errors, 'HASH_MISMATCH', 'patch.digest', 'the patch content does not match its pinned hash');
|
|
115
|
+
if (!isDigest(m.inputsDigest) || !isPlainObject(m.inputs) || digestOf(m.inputs) !== m.inputsDigest) err(errors, 'HASH_MISMATCH', 'inputsDigest', 'the inputs do not match their pinned hash');
|
|
116
|
+
|
|
117
|
+
// Paths are part of the allowed execution scope: a fixture or patch that names a file outside the workspace is refused here,
|
|
118
|
+
// before anything can run, even when its hash matches.
|
|
119
|
+
for (const [label, files] of [['fixture', fixtureFiles], ['patch', patchFiles]]) {
|
|
120
|
+
for (const rel of Object.keys(isPlainObject(files) ? files : {})) {
|
|
121
|
+
if (!isSafeRelativePath(rel)) err(errors, 'SCOPE_VIOLATION', `${label}.files`, `path ${JSON.stringify(String(rel).slice(0, 60))} is not a safe relative path inside the workspace`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (typeof m.entry === 'string' && isPlainObject(fixtureFiles) && !(m.entry in { ...fixtureFiles, ...(isPlainObject(patchFiles) ? patchFiles : {}) })) err(errors, 'SCOPE_VIOLATION', 'entry', 'the entry must be one of the pinned files');
|
|
125
|
+
|
|
126
|
+
const tc = m.toolchain;
|
|
127
|
+
if (!isPlainObject(tc) || tc.runtime !== 'node' || typeof tc.version !== 'string' || typeof tc.platform !== 'string' || typeof tc.arch !== 'string') err(errors, 'BAD_TYPE', 'toolchain', 'needs runtime node, version, platform and arch');
|
|
128
|
+
else if (tc.containerDigest !== null && !isDigest(tc.containerDigest)) err(errors, 'BAD_DIGEST', 'toolchain.containerDigest', 'must be null or sha256:<64 hex>');
|
|
129
|
+
|
|
130
|
+
const oracle = isPlainObject(m.oracle) ? registry.getOracle(m.oracle.id) : null;
|
|
131
|
+
if (!oracle) err(errors, 'UNKNOWN_ORACLE', 'oracle.id', 'the manifest names an oracle this build does not have');
|
|
132
|
+
else {
|
|
133
|
+
if (m.oracle.version !== oracle.version || m.oracle.logicDigest !== oracle.logicDigest) err(errors, 'ORACLE_MISMATCH', 'oracle', 'the oracle version or logic digest differs from the pinned one: this is not the oracle the manifest was made with');
|
|
134
|
+
const sc = m.scope;
|
|
135
|
+
const want = allowedScope(oracle.id);
|
|
136
|
+
if (!isPlainObject(sc) || Object.keys(sc).some((k) => !(k in want))) err(errors, 'SCOPE_VIOLATION', 'scope', 'unknown scope keys');
|
|
137
|
+
else {
|
|
138
|
+
if (JSON.stringify(sc.executes) !== JSON.stringify(want.executes)) err(errors, 'SCOPE_VIOLATION', 'scope.executes', 'a replay may execute only the oracle it names');
|
|
139
|
+
if (sc.network !== false) err(errors, 'SCOPE_VIOLATION', 'scope.network', 'replay grants no network access');
|
|
140
|
+
if (sc.writes !== 'workspace-only') err(errors, 'SCOPE_VIOLATION', 'scope.writes', 'replay writes only inside its workspace');
|
|
141
|
+
}
|
|
142
|
+
const b = m.budgets;
|
|
143
|
+
if (!isPlainObject(b)) err(errors, 'BAD_TYPE', 'budgets', 'must be an object');
|
|
144
|
+
else {
|
|
145
|
+
for (const k of Object.keys(b)) if (!REPLAY_BUDGET_KEYS.includes(k)) err(errors, 'UNKNOWN_FIELD', `budgets.${k}`, 'not a replay budget');
|
|
146
|
+
for (const k of REPLAY_BUDGET_KEYS) {
|
|
147
|
+
if (!Number.isInteger(b[k]) || b[k] < 1) err(errors, 'BAD_TYPE', `budgets.${k}`, 'must be a positive integer');
|
|
148
|
+
else if (b[k] > oracle.budgets[k]) err(errors, 'BUDGET_EXCEEDED', `budgets.${k}`, `exceeds the oracle ceiling of ${oracle.budgets[k]}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (typeof m.entry !== 'string' || !m.entry) err(errors, 'BAD_TYPE', 'entry', 'must be a non-empty string');
|
|
153
|
+
if (m.expected !== undefined && (!isPlainObject(m.expected) || Object.keys(m.expected).some((k) => k !== 'outcome') || !EXPECTED_OUTCOMES.includes(m.expected.outcome))) err(errors, 'BAD_TYPE', 'expected', `must be { outcome: one of ${EXPECTED_OUTCOMES.join(', ')} }`);
|
|
154
|
+
if (m.id !== manifestId(m)) err(errors, 'ID_MISMATCH', 'id', 'the manifest id does not match its content');
|
|
155
|
+
return { ok: errors.length === 0, errors };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------- prerequisites
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Typed prerequisites that are not met on this host. `resumable` is true when
|
|
162
|
+
* meeting it is something an operator can do (install a toolchain, enable the
|
|
163
|
+
* feature) and false when this build cannot meet it at all.
|
|
164
|
+
*/
|
|
165
|
+
export function unmetPrerequisites(m, host = environmentIdentity()) {
|
|
166
|
+
const out = [];
|
|
167
|
+
const tc = m.toolchain;
|
|
168
|
+
if (tc.containerDigest !== null) {
|
|
169
|
+
out.push({ kind: 'container-runtime', id: tc.containerDigest, state: 'unsupported', resumable: false, reason: 'container execution is not implemented in this build; the run is never executed outside the container the manifest names' });
|
|
170
|
+
out.push({ kind: 'container-image', id: tc.containerDigest, state: 'missing', resumable: false, reason: 'this build neither holds nor pulls container images' });
|
|
171
|
+
out.push({ kind: 'network', id: 'image-acquisition', state: 'denied', resumable: false, reason: 'acquiring a container image needs network access, which replay does not grant' });
|
|
172
|
+
}
|
|
173
|
+
if (tc.version !== host.version || tc.platform !== host.platform || tc.arch !== host.arch) {
|
|
174
|
+
out.push({
|
|
175
|
+
kind: 'toolchain', id: `${tc.runtime}@${tc.version} ${tc.platform}/${tc.arch}`, state: 'missing', resumable: true,
|
|
176
|
+
reason: `this host runs ${host.runtime}@${host.version} ${host.platform}/${host.arch}; the manifest pins an exact toolchain`,
|
|
177
|
+
});
|
|
178
|
+
out.push({ kind: 'network', id: 'toolchain-acquisition', state: 'denied', resumable: true, reason: 'installing the pinned toolchain needs network access, which replay does not grant; install it out of band and resume' });
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function nextAttempt(resume, manifestId_) {
|
|
184
|
+
if (resume === undefined || resume === null) return { n: 1, history: [] };
|
|
185
|
+
if (!isPlainObject(resume) || resume.schema !== ATTEMPT_SCHEMA || resume.manifestId !== manifestId_) return { error: 'the attempt does not belong to this manifest' };
|
|
186
|
+
if (resume.status !== 'awaiting-prerequisites') return { error: `an attempt in status '${resume.status}' is not resumable` };
|
|
187
|
+
if (!Number.isInteger(resume.attempt) || resume.attempt < 1 || !Array.isArray(resume.history)) return { error: 'the attempt record is malformed' };
|
|
188
|
+
if (resume.attempt >= MAX_ATTEMPTS) return { error: `the attempt limit of ${MAX_ATTEMPTS} was reached` };
|
|
189
|
+
return { n: resume.attempt + 1, history: resume.history.slice(-MAX_ATTEMPTS) };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function attemptRecord(manifestId_, n, history, status, entry) {
|
|
193
|
+
return { schema: ATTEMPT_SCHEMA, manifestId: manifestId_, attempt: n, status, history: [...history, { attempt: n, ...entry }] };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Replay a manifest. Never throws.
|
|
198
|
+
*
|
|
199
|
+
* @param {object} bundle { manifest, fixtureFiles, patchFiles? }
|
|
200
|
+
* @param {object} [o]
|
|
201
|
+
* @param {object} [o.resume] an attempt returned earlier with status 'awaiting-prerequisites'
|
|
202
|
+
* @param {object} [o.hostEnvironment] environment identity override (test seam)
|
|
203
|
+
* @param {object} [o.config] assurance config (the verification-oracles feature is off by default)
|
|
204
|
+
* @param {object} [o.runOptions] passed to runOracle (test seams: deps, probeEnv, signal)
|
|
205
|
+
* @returns {Promise<object>} { status: 'completed'|'awaiting-prerequisites'|'rejected', executed, outcome, record, receipt, reproduced, prerequisites, attempt, errors }
|
|
206
|
+
*/
|
|
207
|
+
export async function replayManifest(bundle, o = {}) {
|
|
208
|
+
const { manifest, fixtureFiles, patchFiles = null } = bundle || {};
|
|
209
|
+
const valid = validateReplayManifest(manifest, { fixtureFiles, patchFiles });
|
|
210
|
+
if (!valid.ok) return { status: 'rejected', executed: false, outcome: null, record: null, receipt: null, reproduced: null, prerequisites: [], attempt: null, errors: valid.errors };
|
|
211
|
+
|
|
212
|
+
const step = nextAttempt(o.resume, manifest.id);
|
|
213
|
+
if (step.error) return { status: 'rejected', executed: false, outcome: null, record: null, receipt: null, reproduced: null, prerequisites: [], attempt: null, errors: [{ code: 'ATTEMPT_REJECTED', path: 'resume', message: step.error }] };
|
|
214
|
+
|
|
215
|
+
const request = {
|
|
216
|
+
oracleId: manifest.oracle.id, hypothesisId: manifest.hypothesisId, commit: manifest.repository.commit,
|
|
217
|
+
files: { ...fixtureFiles, ...(patchFiles || {}) }, entry: manifest.entry, inputs: manifest.inputs, attempt: step.n,
|
|
218
|
+
budgets: manifest.budgets,
|
|
219
|
+
};
|
|
220
|
+
const waiting = (prerequisites, outcome, reason) => {
|
|
221
|
+
const record = recordWithoutExecution(manifest.oracle.id, request, outcome, reason, registry);
|
|
222
|
+
return {
|
|
223
|
+
status: 'awaiting-prerequisites', executed: false, outcome, record, receipt: null, reproduced: null, prerequisites, errors: [],
|
|
224
|
+
attempt: attemptRecord(manifest.id, step.n, step.history, 'awaiting-prerequisites', { outcome, prerequisites: prerequisites.map((p) => `${p.kind}:${p.state}`) }),
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const unmet = unmetPrerequisites(manifest, o.hostEnvironment || environmentIdentity());
|
|
229
|
+
if (unmet.length) {
|
|
230
|
+
const never = unmet.some((p) => !p.resumable);
|
|
231
|
+
return waiting(unmet, never ? 'unsupported' : 'not-run', `replay prerequisite(s) not met: ${unmet.map((p) => `${p.kind} ${p.id} (${p.state})`).join('; ')}; nothing was executed`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const run = await runOracle(request, { config: o.config, ...(o.runOptions || {}) });
|
|
235
|
+
if (run.status === 'rejected') return { status: 'rejected', executed: false, outcome: null, record: null, receipt: null, reproduced: null, prerequisites: [], attempt: null, errors: (run.errors || []).map((m) => ({ code: 'REQUEST_REJECTED', path: '', message: m })) };
|
|
236
|
+
if (!run.executed) {
|
|
237
|
+
const feature = run.status === 'disabled';
|
|
238
|
+
const prereq = feature
|
|
239
|
+
? [{ kind: 'feature', id: FEATURE_ID, state: 'disabled', resumable: true, reason: run.reason }]
|
|
240
|
+
: (run.prerequisites || [{ id: 'trust-boundary', reason: run.reason }]).map((p) => ({ kind: p.id === 'trust-boundary' ? 'trust-boundary' : 'oracle-prerequisite', id: p.id, state: 'unmet', resumable: false, reason: p.reason }));
|
|
241
|
+
return waiting(prereq, run.outcome === 'unsupported' ? 'unsupported' : 'not-run', run.reason);
|
|
242
|
+
}
|
|
243
|
+
const reproduced = manifest.expected ? run.outcome === manifest.expected.outcome : null;
|
|
244
|
+
return {
|
|
245
|
+
status: 'completed', executed: true, outcome: run.outcome, record: run.record, receipt: run.receipt, reproduced, disclosure: run.disclosure,
|
|
246
|
+
prerequisites: [], errors: [], run: run.run, environment: run.environment,
|
|
247
|
+
attempt: attemptRecord(manifest.id, step.n, step.history, 'completed', { outcome: run.outcome }),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Advisory output can never reach authoritative state (X-207.AC01).
|
|
2
|
+
//
|
|
3
|
+
// Hunt (`discovery/`) is advisory: a model proposes candidates and the output is a hypothesis, never a finding that gates a
|
|
4
|
+
// release. Two ways that separation could fail quietly are closed here, both by construction rather than by convention:
|
|
5
|
+
//
|
|
6
|
+
// 1. SHARED-FILE SIDE EFFECTS. Advisory code writes to exactly one file name from a closed allowlist, through
|
|
7
|
+
// `writeAdvisoryState`. The write goes to a fresh temporary file that is renamed over the target, so a target that is a
|
|
8
|
+
// symlink or a hard link to `last-scan.json` (planted by a hostile tree) is REPLACED, never written through. The state
|
|
9
|
+
// directory must resolve inside the project root. A name outside the allowlist, a path separator in a name, or the
|
|
10
|
+
// authoritative scan files are refused with a typed reason.
|
|
11
|
+
// 2. SERIALIZATION. A hypothesis is recognisable (`isAdvisoryHypothesis`) from what the hunt pipeline stamps on it, and the
|
|
12
|
+
// report normalizer excludes it from every gating output. Promotion into a finding is the only way across, and it is a
|
|
13
|
+
// different shape (`hypothesis-promotion.js`), so a hypothesis cannot be turned into a blocker by being merged into a
|
|
14
|
+
// scan's finding list.
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import * as crypto from 'node:crypto';
|
|
18
|
+
import { isPlainObject } from '../assurance/schema-kit.js';
|
|
19
|
+
import { STATE_DIR_NAME, stateWritesEnabled } from '../state-dir.js';
|
|
20
|
+
|
|
21
|
+
/** The state file names advisory code may write. Everything else is refused. */
|
|
22
|
+
export const ADVISORY_STATE_FILES = Object.freeze(['discovery-memory.json']);
|
|
23
|
+
|
|
24
|
+
/** Files that decide a gate and that no advisory writer may name, even by mistake in the allowlist above. */
|
|
25
|
+
const AUTHORITATIVE_STATE_FILES = Object.freeze(['last-scan.json', 'last-scan.json.sig', 'rules.yml', 'rules.yml.sig', 'scan-key']);
|
|
26
|
+
|
|
27
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
/** True for a hunt hypothesis: it carries the hunt stamp (`parser: DISCOVERY` and/or a `discovery` object). */
|
|
30
|
+
export function isAdvisoryHypothesis(f) {
|
|
31
|
+
return isPlainObject(f) && (f.parser === 'DISCOVERY' || isPlainObject(f.discovery));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Write an advisory state file. Returns `{ ok: true, path }` or `{ ok: false, code, reason }`; never throws.
|
|
36
|
+
* `body` is a string. The write is atomic (temp file + rename) and never follows a link at the target.
|
|
37
|
+
*/
|
|
38
|
+
export function writeAdvisoryState(scanRoot, name, body) {
|
|
39
|
+
const refuse = (code, reason) => ({ ok: false, code, reason });
|
|
40
|
+
try {
|
|
41
|
+
if (typeof name !== 'string' || name.includes('/') || name.includes('\\') || name.includes('\0') || name === '' || name.startsWith('.')) {
|
|
42
|
+
return refuse('bad-name', 'an advisory state file name is a plain file name');
|
|
43
|
+
}
|
|
44
|
+
if (AUTHORITATIVE_STATE_FILES.includes(name) || /^last-scan/.test(name)) return refuse('authoritative', `'${name}' is authoritative scan state; advisory output never writes it`);
|
|
45
|
+
if (!ADVISORY_STATE_FILES.includes(name)) return refuse('not-allowlisted', `'${name}' is not an advisory state file`);
|
|
46
|
+
if (!stateWritesEnabled()) return refuse('state-writes-disabled', 'state writes are switched off for this process (read-only scan)');
|
|
47
|
+
if (typeof body !== 'string' || Buffer.byteLength(body) > MAX_BYTES) return refuse('bad-body', `advisory state is a string of at most ${MAX_BYTES} bytes`);
|
|
48
|
+
if (typeof scanRoot !== 'string' || !scanRoot) return refuse('no-root', 'no project root');
|
|
49
|
+
const rootReal = fs.realpathSync(scanRoot);
|
|
50
|
+
const dir = path.join(rootReal, STATE_DIR_NAME);
|
|
51
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
52
|
+
const dirReal = fs.realpathSync(dir);
|
|
53
|
+
if (dirReal !== path.join(rootReal, STATE_DIR_NAME)) return refuse('state-dir-escapes', 'the state directory resolves outside the project root');
|
|
54
|
+
const target = path.join(dirReal, name);
|
|
55
|
+
const tmp = path.join(dirReal, `.${name}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
56
|
+
let fd = null;
|
|
57
|
+
try {
|
|
58
|
+
fd = fs.openSync(tmp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o644);
|
|
59
|
+
fs.writeSync(fd, body);
|
|
60
|
+
fs.closeSync(fd); fd = null;
|
|
61
|
+
fs.renameSync(tmp, target);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch { /* best effort */ } }
|
|
64
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
65
|
+
return refuse('write-failed', String(e?.message || e).slice(0, 160));
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, path: target };
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return refuse('write-failed', String(e?.message || e).slice(0, 160));
|
|
70
|
+
}
|
|
71
|
+
}
|