@clear-capabilities/agentic-security-scanner 0.161.0 → 0.163.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 +46 -0
- package/bin/agentic-security.js +98 -2
- package/dist/1340.index.js +1871 -0
- package/dist/3477.index.js +727 -0
- package/dist/3499.index.js +23 -3
- package/dist/4113.index.js +59 -8
- package/dist/5014.index.js +833 -0
- package/dist/5756.index.js +2 -2
- package/dist/6257.index.js +1 -1
- package/dist/6907.index.js +248 -0
- package/dist/6994.index.js +1 -1
- package/dist/7033.index.js +33 -5
- package/dist/7439.index.js +1822 -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 +2336 -27
- package/dist/957.index.js +1 -1
- package/dist/{1905.index.js → 9999.index.js} +306 -13
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +39 -7
- package/src/capabilities/CLAUDE.md +114 -0
- package/src/capabilities/address.js +73 -0
- package/src/capabilities/attack-coverage.js +93 -0
- package/src/capabilities/decide.js +256 -0
- package/src/capabilities/delegate.js +69 -0
- package/src/capabilities/egress.js +20 -0
- package/src/capabilities/hook-advice.js +50 -0
- package/src/capabilities/index.js +32 -0
- package/src/capabilities/manifest.js +344 -0
- package/src/capabilities/outbound.js +189 -0
- package/src/capabilities/paths.js +57 -0
- package/src/capabilities/probes.js +211 -0
- package/src/capabilities/proxy.js +182 -0
- package/src/capabilities/reasons.js +67 -0
- package/src/capabilities/receipts.js +356 -0
- package/src/capabilities/records.js +58 -0
- package/src/capabilities/recovery.js +367 -0
- package/src/capabilities/report.js +81 -0
- package/src/capabilities/runner.js +236 -0
- package/src/capabilities/secrets.js +26 -0
- package/src/capabilities/tool-gate.js +119 -0
- package/src/capabilities/tool-registry.js +60 -0
- package/src/dataflow/CLAUDE.md +29 -0
- package/src/dataflow/catalog.js +21 -0
- package/src/dataflow/engine.js +20 -0
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +18 -0
- package/src/discovery/index.js +43 -1
- package/src/discovery/lenses.js +18 -0
- package/src/discovery/memory.js +7 -5
- package/src/engine.js +80 -15
- package/src/fix/apply-fix-service.js +33 -3
- package/src/integrations/index.js +5 -2
- package/src/ir/CLAUDE.md +14 -0
- package/src/ir/implicit-handler-params.js +74 -0
- package/src/ir/parser-cs.js +20 -0
- package/src/ir/parser-go.js +30 -13
- package/src/ir/parser-java.js +11 -0
- package/src/language/nix-eval-isolation.js +33 -5
- package/src/lineage/CLAUDE.md +31 -0
- package/src/lineage/deployment/adapter-cloud.js +202 -0
- package/src/lineage/deployment/adapter-compose.js +90 -0
- package/src/lineage/deployment/adapter-kit.js +142 -0
- package/src/lineage/deployment/adapter-kubernetes.js +247 -0
- package/src/lineage/deployment/attack-paths.js +443 -0
- package/src/lineage/deployment/boundaries-run.js +223 -0
- package/src/lineage/deployment/boundary-graph.js +567 -0
- package/src/lineage/deployment/cross-repo.js +253 -0
- package/src/lineage/deployment/drift.js +383 -0
- package/src/lineage/deployment/ingest.js +221 -0
- package/src/lineage/deployment/projection.js +244 -0
- package/src/lineage/deployment/trace-correlation.js +541 -0
- package/src/lineage/source-registry.js +19 -0
- package/src/mcp/CLAUDE.md +14 -3
- package/src/mcp/invariant-tools.js +64 -0
- package/src/mcp/portfolio-tools.js +79 -0
- package/src/mcp/redact.js +15 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/tools.js +30 -5
- package/src/posture/CLAUDE.md +505 -0
- package/src/posture/accuracy-scorecard.js +95 -4
- 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/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -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/business-logic.js +26 -4
- package/src/posture/evaluation/bakeoff.js +148 -0
- package/src/posture/evaluation/custody.js +200 -0
- package/src/posture/evaluation/deployment-ablation.js +416 -0
- package/src/posture/evaluation/dev-cases.js +65 -0
- package/src/posture/evaluation/economics.js +85 -0
- package/src/posture/evaluation/gates.js +161 -0
- package/src/posture/evaluation/grouping.js +101 -0
- package/src/posture/evaluation/interval.js +132 -0
- package/src/posture/evaluation/invariant-ablation.js +325 -0
- package/src/posture/evaluation/labels.js +200 -0
- package/src/posture/evaluation/protocol.js +308 -0
- package/src/posture/evaluation/release-gate.js +209 -0
- package/src/posture/evaluation/report.js +158 -0
- package/src/posture/evaluation/runner.js +273 -0
- package/src/posture/evaluation/scan-child.js +80 -0
- package/src/posture/evaluation/score.js +174 -0
- package/src/posture/evaluation/synthetic.js +90 -0
- package/src/posture/evaluation/why-missed.js +219 -0
- 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/fleet.js +11 -2
- package/src/posture/invariants/cli.js +95 -0
- package/src/posture/invariants/coverage.js +164 -0
- package/src/posture/invariants/export.js +178 -0
- package/src/posture/invariants/expressions.js +46 -0
- package/src/posture/invariants/lifecycle.js +289 -0
- package/src/posture/invariants/project-input.js +55 -0
- package/src/posture/invariants/repair.js +340 -0
- package/src/posture/invariants/run.js +45 -0
- package/src/posture/invariants/scenarios.js +311 -0
- package/src/posture/invariants/schema.js +167 -0
- package/src/posture/invariants/shrink.js +407 -0
- package/src/posture/invariants/state-assertions.js +165 -0
- package/src/posture/logic-claims.js +12 -0
- package/src/posture/model-routing.js +40 -0
- package/src/posture/oracles/adapters.js +531 -0
- package/src/posture/oracles/conformance.js +276 -0
- package/src/posture/oracles/oracle.js +453 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/portfolio/backend.js +279 -0
- package/src/posture/portfolio/bundle.js +314 -0
- package/src/posture/portfolio/cli.js +142 -0
- package/src/posture/portfolio/manifest.js +263 -0
- package/src/posture/portfolio/progress.js +267 -0
- package/src/posture/portfolio/resume.js +130 -0
- package/src/posture/portfolio/retention.js +207 -0
- package/src/posture/portfolio/scheduler.js +488 -0
- package/src/posture/portfolio/signing.js +155 -0
- package/src/posture/portfolio/wording.js +59 -0
- package/src/posture/portfolio/work-units.js +405 -0
- package/src/posture/replay/replay.js +250 -0
- package/src/posture/routing/adapter.js +140 -0
- package/src/posture/routing/calibration.js +278 -0
- package/src/posture/routing/canary.js +141 -0
- package/src/posture/routing/control.js +35 -0
- package/src/posture/routing/decide.js +211 -0
- package/src/posture/routing/drift.js +137 -0
- package/src/posture/routing/economics.js +185 -0
- package/src/posture/routing/feedback.js +207 -0
- package/src/posture/routing/index.js +31 -0
- package/src/posture/routing/outcomes.js +281 -0
- package/src/posture/routing/promotion.js +291 -0
- package/src/posture/routing/receipts.js +59 -0
- package/src/posture/routing/report.js +142 -0
- package/src/posture/routing/shadow.js +53 -0
- package/src/posture/specification-mining.js +26 -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 +226 -0
- package/src/posture/verification/patch-promotion.js +113 -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 +100 -1
- package/src/sandbox/CLAUDE.md +51 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +151 -34
- 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,124 @@
|
|
|
1
|
+
// Separate records for patch application, rejection and rollback (X-204.AC03).
|
|
2
|
+
//
|
|
3
|
+
// The CORE-002 verification record carries ONE repair status (`none`, `proposed`, `applied`, `replay-verified`,
|
|
4
|
+
// `rolled-back`). That field answers "where is this repair now"; it cannot also be the history, and it has no value for a
|
|
5
|
+
// rejection. So the history is kept here as an append-only ledger of small records, one per event, each with its own id and
|
|
6
|
+
// a link to the record before it. A rollback is therefore a NEW record after an application, not an edit that erases it, and
|
|
7
|
+
// a rejection is a record of its own, not a status that vanishes when the patch is dropped.
|
|
8
|
+
//
|
|
9
|
+
// Rules the ledger enforces, each with a test (the state is per hypothesis and exact diff):
|
|
10
|
+
// proposed first event for a diff.
|
|
11
|
+
// rejected from proposed or promoted. Needs a reason; carries the incomplete step when verification caused it. Terminal.
|
|
12
|
+
// promoted from proposed, and ONLY through `recordPromotion`, which accepts nothing but a result `promotePatch` itself
|
|
13
|
+
// returned `ok: true` for. The generic append refuses this kind, so a caller cannot write a promotion by hand.
|
|
14
|
+
// applied from promoted only: nothing is applied that was not promoted for that exact diff.
|
|
15
|
+
// rolled-back from applied or promoted (a partial application that was undone). Terminal.
|
|
16
|
+
// `verificationRepairFor` maps the ledger onto the CORE-002 repair field; a rejection has no value there, so the record keeps
|
|
17
|
+
// `proposed` and the ledger holds the rejection (the enum is a foundation contract this change does not widen).
|
|
18
|
+
import { semanticId } from '../assurance/identity.js';
|
|
19
|
+
import { buildVerificationRecord, validateVerificationRecord } from '../assurance/verification-record.js';
|
|
20
|
+
import { isCommit, isDigest, isPlainObject } from '../assurance/schema-kit.js';
|
|
21
|
+
import { isIssuedPromotion } from './patch-promotion.js';
|
|
22
|
+
|
|
23
|
+
const REPAIR_RECORD_SCHEMA = 'agentic-security/repair-record';
|
|
24
|
+
const REPAIR_RECORD_VERSION = '1.0.0';
|
|
25
|
+
const REPAIR_KINDS = Object.freeze(['proposed', 'rejected', 'promoted', 'applied', 'rolled-back']);
|
|
26
|
+
|
|
27
|
+
const ID_FIELDS = ['kind', 'hypothesisId', 'revision', 'diffDigest', 'reason', 'step', 'receiptDigests', 'verificationRecordId', 'previous'];
|
|
28
|
+
const TRANSITIONS = Object.freeze({
|
|
29
|
+
proposed: [null],
|
|
30
|
+
rejected: ['proposed', 'promoted'],
|
|
31
|
+
promoted: ['proposed'],
|
|
32
|
+
applied: ['promoted'],
|
|
33
|
+
'rolled-back': ['applied', 'promoted'],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function build(fields) {
|
|
37
|
+
const rec = {
|
|
38
|
+
schema: REPAIR_RECORD_SCHEMA, schemaVersion: REPAIR_RECORD_VERSION,
|
|
39
|
+
kind: fields.kind, hypothesisId: fields.hypothesisId, revision: fields.revision ?? null, diffDigest: fields.diffDigest,
|
|
40
|
+
reason: fields.reason ?? null, step: fields.step ?? null, receiptDigests: fields.receiptDigests ?? null,
|
|
41
|
+
verificationRecordId: fields.verificationRecordId ?? null, previous: fields.previous ?? null,
|
|
42
|
+
};
|
|
43
|
+
rec.id = semanticId('rrec', rec, ID_FIELDS);
|
|
44
|
+
return Object.freeze(rec);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The latest event kind recorded for a hypothesis and exact diff, or null. */
|
|
48
|
+
function stateOf(records, hypothesisId, diffDigest) {
|
|
49
|
+
for (let i = records.length - 1; i >= 0; i--) {
|
|
50
|
+
if (records[i].hypothesisId === hypothesisId && records[i].diffDigest === diffDigest) return records[i];
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function check(records, fields) {
|
|
56
|
+
const errs = [];
|
|
57
|
+
if (!REPAIR_KINDS.includes(fields.kind)) errs.push(`unknown repair kind '${fields.kind}'`);
|
|
58
|
+
if (typeof fields.hypothesisId !== 'string' || !fields.hypothesisId) errs.push('hypothesisId is required');
|
|
59
|
+
if (!isDigest(fields.diffDigest)) errs.push('diffDigest must be sha256:<64 hex>');
|
|
60
|
+
if (fields.revision !== undefined && fields.revision !== null && !isCommit(fields.revision)) errs.push('revision must be an exact commit or null');
|
|
61
|
+
if (['rejected', 'rolled-back'].includes(fields.kind) && !(typeof fields.reason === 'string' && fields.reason.trim())) errs.push(`a '${fields.kind}' record needs a reason`);
|
|
62
|
+
if (errs.length) return errs;
|
|
63
|
+
const last = stateOf(records, fields.hypothesisId, fields.diffDigest);
|
|
64
|
+
const from = last ? last.kind : null;
|
|
65
|
+
if (!TRANSITIONS[fields.kind].includes(from)) errs.push(`'${fields.kind}' cannot follow ${from ? `'${from}'` : 'no earlier record'} for this diff`);
|
|
66
|
+
return errs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Append one event. Returns a NEW list (the input is never changed) and the record. `promoted` is refused here: use
|
|
71
|
+
* `recordPromotion`. Never throws.
|
|
72
|
+
* @returns {{ ok: boolean, records: object[], record: object|null, errors: string[] }}
|
|
73
|
+
*/
|
|
74
|
+
export function appendRepairRecord(records, fields) {
|
|
75
|
+
const list = Array.isArray(records) ? records : [];
|
|
76
|
+
if (!isPlainObject(fields)) return { ok: false, records: list, record: null, errors: ['fields must be an object'] };
|
|
77
|
+
if (fields.kind === 'promoted') return { ok: false, records: list, record: null, errors: ['a promotion is recorded only through recordPromotion, from a promotePatch result'] };
|
|
78
|
+
const errors = check(list, fields);
|
|
79
|
+
if (errors.length) return { ok: false, records: list, record: null, errors };
|
|
80
|
+
const last = stateOf(list, fields.hypothesisId, fields.diffDigest);
|
|
81
|
+
const record = build({ ...fields, previous: last ? last.id : null });
|
|
82
|
+
return { ok: true, records: [...list, record], record, errors: [] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Record a promotion. Accepts only a result `promotePatch` returned with `ok: true`. */
|
|
86
|
+
export function recordPromotion(records, promotion, { verificationRecordId = null } = {}) {
|
|
87
|
+
const list = Array.isArray(records) ? records : [];
|
|
88
|
+
if (!isIssuedPromotion(promotion)) return { ok: false, records: list, record: null, errors: ['not a promotion issued by promotePatch'] };
|
|
89
|
+
const fields = {
|
|
90
|
+
kind: 'promoted', hypothesisId: promotion.hypothesisId, revision: promotion.revision, diffDigest: promotion.diffDigest,
|
|
91
|
+
reason: 'independent verifier receipts match the exact proposed diff and revision', receiptDigests: promotion.receiptDigests, verificationRecordId,
|
|
92
|
+
};
|
|
93
|
+
const errors = check(list, fields);
|
|
94
|
+
if (errors.length) return { ok: false, records: list, record: null, errors };
|
|
95
|
+
const last = stateOf(list, fields.hypothesisId, fields.diffDigest);
|
|
96
|
+
const record = build({ ...fields, previous: last ? last.id : null });
|
|
97
|
+
return { ok: true, records: [...list, record], record, errors: [] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The current CORE-002 `repair` object for a diff, derived from the ledger. */
|
|
101
|
+
export function verificationRepairFor(records, hypothesisId, diffDigest, patchedRecordId = null) {
|
|
102
|
+
const last = stateOf(Array.isArray(records) ? records : [], hypothesisId, diffDigest);
|
|
103
|
+
if (!last) return { status: 'none' };
|
|
104
|
+
if (last.kind === 'applied') return { status: 'applied', patchDigest: diffDigest };
|
|
105
|
+
if (last.kind === 'rolled-back') return { status: 'rolled-back', patchDigest: diffDigest };
|
|
106
|
+
if (last.kind === 'promoted' && typeof patchedRecordId === 'string' && patchedRecordId.startsWith('vrec:')) {
|
|
107
|
+
return { status: 'replay-verified', patchDigest: diffDigest, replayRecordId: patchedRecordId };
|
|
108
|
+
}
|
|
109
|
+
return { status: 'proposed', patchDigest: diffDigest };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A copy of a verification record with its repair status replaced, rebuilt and re-validated (the id is recomputed, the
|
|
114
|
+
* confirmation level stays what its evidence supports). Returns null if the result is not a valid record.
|
|
115
|
+
*/
|
|
116
|
+
export function withRepairStatus(record, repair) {
|
|
117
|
+
if (!isPlainObject(record)) return null;
|
|
118
|
+
const rebuilt = buildVerificationRecord({
|
|
119
|
+
hypothesisId: record.hypothesisId, commit: record.commit, detectorOrigin: record.detectorOrigin, oracle: record.oracle,
|
|
120
|
+
attempt: record.attempt, outcome: record.outcome, reason: record.reason, evidence: record.evidence, evidenceRefs: record.evidenceRefs,
|
|
121
|
+
scope: record.scope, preconditions: record.preconditions, repair,
|
|
122
|
+
});
|
|
123
|
+
return validateVerificationRecord(rebuilt).ok ? rebuilt : null;
|
|
124
|
+
}
|
package/src/posture/verifier.js
CHANGED
|
@@ -33,6 +33,7 @@ import * as os from 'node:os';
|
|
|
33
33
|
import { runConfined, sandboxAvailable } from '../sandbox/index.js';
|
|
34
34
|
import { isExplicitlyNoPoc } from './poc-cwe-map.js';
|
|
35
35
|
import { isSafeStateDir, stateDir, statePath, stateWritesEnabled } from './state-dir.js';
|
|
36
|
+
import { emitVerification } from './verification/emit.js';
|
|
36
37
|
|
|
37
38
|
// ─── PoC static validation ──────────────────────────────────────────────────
|
|
38
39
|
//
|
|
@@ -218,6 +219,21 @@ export function annotateVerifierVerdicts(findings, opts = {}) {
|
|
|
218
219
|
}
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
// ─── Verification records (X-201) ───────────────────────────────────────────
|
|
223
|
+
//
|
|
224
|
+
// The version-1 record for each annotated finding. Returned, never written onto the finding: `verifier_verdict` and
|
|
225
|
+
// its siblings stay exactly as they were, and a finding that later lands in last-scan.json is not changed by this.
|
|
226
|
+
// None of the verdicts above ran under the trusted boundary, so none maps to `confirmed` or `refuted`.
|
|
227
|
+
export function verifierVerificationRecords(findings, { commit = null } = {}) {
|
|
228
|
+
const out = [];
|
|
229
|
+
for (const f of findings || []) {
|
|
230
|
+
if (!f || typeof f !== 'object' || !f.verifier_verdict) continue;
|
|
231
|
+
const e = emitVerification('verifier', f, { finding: f, commit });
|
|
232
|
+
if (e.ok) out.push(e.record);
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
221
237
|
// ─── Summary helpers ────────────────────────────────────────────────────────
|
|
222
238
|
|
|
223
239
|
export function verifierCoverageSummary(findings) {
|
package/src/report/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { _isCustomSuppressed } from '../engine.js';
|
|
|
4
4
|
import { alertFace, approveFace } from './mascot.js';
|
|
5
5
|
import { SCANNER_VERSION } from '../posture/version.js';
|
|
6
6
|
import { proofBlock } from '../posture/proof-artifact.js';
|
|
7
|
+
import { portfolioAssuranceEnabled, scanVerdictWording, NOT_A_GUARANTEE } from '../posture/portfolio/wording.js';
|
|
7
8
|
import { applyLegacyCompat, legacyFieldDeprecationNotice } from '../pipeline/legacy-compat.js';
|
|
8
9
|
// Finding Provenance (M0/M1). NOTE the name: `findingProvenance` is the
|
|
9
10
|
// git-origin record from posture/provenance/. It is NOT `finding.provenance`
|
|
@@ -12,6 +13,13 @@ import { applyLegacyCompat, legacyFieldDeprecationNotice } from '../pipeline/leg
|
|
|
12
13
|
// (sca/sigstore-verify.js's SLSA/Sigstore build attestation). Three unrelated
|
|
13
14
|
// things, three distinct keys — do not collapse them.
|
|
14
15
|
import { redactFindingProvenance, sanitizeForTerminal } from '../posture/provenance/schema.js';
|
|
16
|
+
// X-206: the one projection of the verification record. This report, the MCP tools and the autopilot response all call it.
|
|
17
|
+
import { verificationFields, verificationCoverage } from '../posture/verification/projection.js';
|
|
18
|
+
import { invariantCoverageFields } from '../posture/invariants/coverage.js';
|
|
19
|
+
// X-207: a hunt hypothesis is advisory and is never a finding in any gating output; promotion is the only way across.
|
|
20
|
+
import { isAdvisoryHypothesis } from '../posture/verification/advisory-state.js';
|
|
21
|
+
// X-307: the one projection of the deployment boundary context. Additive and present only when a finding carries a context.
|
|
22
|
+
import { boundaryFields, boundaryCoverage } from '../lineage/deployment/projection.js';
|
|
15
23
|
// Re-exported: FR-PROV-026. Lives in provenance/schema.js (shared with
|
|
16
24
|
// posture/auditor-walkthrough.js, a second CLI renderer of the same
|
|
17
25
|
// untrusted fields — see that module's header for why it lives there
|
|
@@ -222,7 +230,11 @@ export function normalizeFindings(scan){
|
|
|
222
230
|
});
|
|
223
231
|
return true;
|
|
224
232
|
};
|
|
233
|
+
const advisoryExcluded = [];
|
|
225
234
|
for (const f of (scan.findings||[])) {
|
|
235
|
+
// X-207.AC01: an unpromoted hunt hypothesis cannot gate. It is excluded here, the one place every output format and every
|
|
236
|
+
// exit code derives from, and the exclusion is recorded (never silent) as `advisoryExcluded`.
|
|
237
|
+
if (isAdvisoryHypothesis(f)) { advisoryExcluded.push({ id: f.id ?? null, stableId: f.stableId ?? null, reason: 'advisory hunt hypothesis: not a finding until promoted by a verifier receipt and policy' }); continue; }
|
|
226
238
|
if (suppress(f.vuln || f.type, f.file, f.line || f.source?.line || 0, f.snippet)) continue;
|
|
227
239
|
out.push({
|
|
228
240
|
id: f.id || fingerprint(f),
|
|
@@ -328,6 +340,16 @@ export function normalizeFindings(scan){
|
|
|
328
340
|
// a finding with no proof backing simply omits these fields.
|
|
329
341
|
...(f.proofTier !== undefined ? { proofTier: f.proofTier } : {}),
|
|
330
342
|
...(f.proofEvidence !== undefined ? { proofEvidence: f.proofEvidence } : {}),
|
|
343
|
+
// X-206: the version-1 verification record and its shared projection, ADDITIVE and present only when the finding carries
|
|
344
|
+
// a record (a plain scan output is byte-identical). `verificationReplay` is the typed prerequisite list of a replay attempt.
|
|
345
|
+
...(f.verificationRecord && typeof f.verificationRecord === 'object'
|
|
346
|
+
? { verificationRecord: f.verificationRecord, ...(f.verificationReplay ? { verificationReplay: f.verificationReplay } : {}), ...verificationFields(f.verificationRecord, { replay: f.verificationReplay }) }
|
|
347
|
+
: {}),
|
|
348
|
+
// X-307: the deployment boundary context and its projection, ADDITIVE and present only when a producer (the
|
|
349
|
+
// `deployment-boundaries` feature) attached one. A plain scan output is byte-identical.
|
|
350
|
+
...(f.boundaryContext && typeof f.boundaryContext === 'object'
|
|
351
|
+
? boundaryFields(f.boundaryContext)
|
|
352
|
+
: {}),
|
|
331
353
|
// Phase-1 next-gen P1.3 (FR-UX-1, FR-UX-2): calibrated probability +
|
|
332
354
|
// 95% Wilson CI + sample size. Null when N < MIN_SAMPLES_FOR_CALIBRATION
|
|
333
355
|
// for this family; `calibration_reason` explains why.
|
|
@@ -587,6 +609,7 @@ export function normalizeFindings(scan){
|
|
|
587
609
|
// all derive from this one function) — one place, covers every output
|
|
588
610
|
// format, matches this function's own role as "the canonical shape".
|
|
589
611
|
for (const f of out) applyLegacyCompat(f);
|
|
612
|
+
if (advisoryExcluded.length) scan.advisoryExcluded = advisoryExcluded; else delete scan.advisoryExcluded;
|
|
590
613
|
|
|
591
614
|
// Sort by severity tier, then within a tier by EPSS percentile (desc) so that
|
|
592
615
|
// CVEs with active in-the-wild abuse float above theoretical CVEs.
|
|
@@ -734,6 +757,15 @@ export function toJSON(scan, meta={}, opts={}){
|
|
|
734
757
|
// hand-built `scan` object in a test), same convention as the S7 fields
|
|
735
758
|
// above; a real scan always computes it.
|
|
736
759
|
scanHealth: scan.scanHealth || null,
|
|
760
|
+
// X-207: advisory hypotheses a caller put in the finding list; excluded from `findings`, listed here so it is never silent.
|
|
761
|
+
...(Array.isArray(scan.advisoryExcluded) && scan.advisoryExcluded.length ? { advisoryExcluded: scan.advisoryExcluded } : {}),
|
|
762
|
+
// X-206: trusted-negative accounting over the findings that carry a verification record. Absent when none does, so a plain
|
|
763
|
+
// scan's output is unchanged.
|
|
764
|
+
...(findings.some(f => f.verificationRecord) ? { verificationCoverage: verificationCoverage(findings.map(f => f.verificationRecord).filter(Boolean)) } : {}),
|
|
765
|
+
// X-307: boundary accounting over the findings that carry a context; absent when none does.
|
|
766
|
+
...(findings.some(f => f.boundaryContext) ? { boundaryCoverage: boundaryCoverage(findings.map(f => f.boundaryContext).filter(Boolean)) } : {}),
|
|
767
|
+
// X-407: bounded business-logic coverage, when a caller attached one AND the invariant-scenarios feature is on (off by default).
|
|
768
|
+
...invariantCoverageFields(scan.invariantCoverage),
|
|
737
769
|
};
|
|
738
770
|
if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
|
|
739
771
|
return out;
|
|
@@ -903,6 +935,25 @@ function _mdFenceLen(text) {
|
|
|
903
935
|
return Math.max(3, ...runs.map(r => r.length + 1));
|
|
904
936
|
}
|
|
905
937
|
|
|
938
|
+
/**
|
|
939
|
+
* X-206: the human-readable verification block for a normalized finding, from the SAME projection the JSON and MCP surfaces
|
|
940
|
+
* carry (`finding.verification.text`). Null when the finding has no verification record. Terminal control characters are stripped.
|
|
941
|
+
*/
|
|
942
|
+
export function verificationBlock(f) {
|
|
943
|
+
const text = f?.verificationView?.text;
|
|
944
|
+
// sanitizeForTerminal trims, so the line's own indentation is restored after it strips control characters.
|
|
945
|
+
return Array.isArray(text) && text.length ? text.map((l) => String(l).match(/^ */)[0] + sanitizeForTerminal(String(l))) : null;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* X-307: the human-readable boundary block for a normalized finding, from the SAME projection the JSON and MCP surfaces carry.
|
|
950
|
+
* Null when the finding has no boundary context. Terminal control characters are stripped.
|
|
951
|
+
*/
|
|
952
|
+
export function boundaryBlock(f) {
|
|
953
|
+
const text = f?.boundaryView?.text;
|
|
954
|
+
return Array.isArray(text) && text.length ? text.map((l) => String(l).match(/^ */)[0] + sanitizeForTerminal(String(l))) : null;
|
|
955
|
+
}
|
|
956
|
+
|
|
906
957
|
export function toMarkdown(scan, meta={}){
|
|
907
958
|
const findings = normalizeFindings(scan);
|
|
908
959
|
const lines = ['# Agentic Security — Scan Report', ''];
|
|
@@ -935,6 +986,40 @@ export function toMarkdown(scan, meta={}){
|
|
|
935
986
|
lines.push(`| \`${f.file}:${f.line}\` | ${f.vuln} | ${f.cwe||'—'} | ${epss} | ${fix.replace(/\|/g,'\\|').slice(0,140)} |`);
|
|
936
987
|
}
|
|
937
988
|
}
|
|
989
|
+
// X-206: verification blocks come from the shared projection, never a second renderer.
|
|
990
|
+
const withVerification = bySev[sev].filter(f => verificationBlock(f));
|
|
991
|
+
if (withVerification.length) {
|
|
992
|
+
lines.push('');
|
|
993
|
+
lines.push('<details><summary>Verification</summary>');
|
|
994
|
+
lines.push('');
|
|
995
|
+
for (const f of withVerification) {
|
|
996
|
+
const block = verificationBlock(f).join('\n');
|
|
997
|
+
lines.push(`**\`${f.file}:${f.line}\`**: ${f.vuln}`);
|
|
998
|
+
const fence = '`'.repeat(_mdFenceLen(block));
|
|
999
|
+
lines.push(fence);
|
|
1000
|
+
lines.push(block);
|
|
1001
|
+
lines.push(fence);
|
|
1002
|
+
lines.push('');
|
|
1003
|
+
}
|
|
1004
|
+
lines.push('</details>');
|
|
1005
|
+
}
|
|
1006
|
+
// X-307: boundary blocks come from the shared projection, never a second renderer.
|
|
1007
|
+
const withBoundary = bySev[sev].filter(f => boundaryBlock(f));
|
|
1008
|
+
if (withBoundary.length) {
|
|
1009
|
+
lines.push('');
|
|
1010
|
+
lines.push('<details><summary>Deployment boundaries</summary>');
|
|
1011
|
+
lines.push('');
|
|
1012
|
+
for (const f of withBoundary) {
|
|
1013
|
+
const block = boundaryBlock(f).join('\n');
|
|
1014
|
+
lines.push(`**\`${f.file}:${f.line}\`**: ${f.vuln}`);
|
|
1015
|
+
const fence = '`'.repeat(_mdFenceLen(block));
|
|
1016
|
+
lines.push(fence);
|
|
1017
|
+
lines.push(block);
|
|
1018
|
+
lines.push(fence);
|
|
1019
|
+
lines.push('');
|
|
1020
|
+
}
|
|
1021
|
+
lines.push('</details>');
|
|
1022
|
+
}
|
|
938
1023
|
// FR-PROV-018: one provenance block per finding that has one, reusing
|
|
939
1024
|
// explainProvenance's content — never a second, divergent renderer.
|
|
940
1025
|
const withProvenance = bySev[sev].filter(f => f.findingProvenance);
|
|
@@ -1432,6 +1517,10 @@ export function toCLI(scan, { verbose=false, color=true, provenance=false }={}){
|
|
|
1432
1517
|
if (ex.how) lines.push(` ${c('how:', DIM)} ${ex.how}`);
|
|
1433
1518
|
if (ex.fix) lines.push(` ${c('fix:', DIM)} ${ex.fix}`);
|
|
1434
1519
|
if (ex.fixCode) for (const ln of ex.fixCode.split('\n').slice(0, 6)) lines.push(` ${c(ln, DIM)}`);
|
|
1520
|
+
const vblock = verificationBlock(f);
|
|
1521
|
+
if (vblock) for (const ln of vblock) lines.push(` ${c(ln, DIM)}`);
|
|
1522
|
+
const bblock = boundaryBlock(f);
|
|
1523
|
+
if (bblock) for (const ln of bblock) lines.push(` ${c(ln, DIM)}`);
|
|
1435
1524
|
if (provenance) {
|
|
1436
1525
|
// `f` here is already normalized, so its findingProvenance has been
|
|
1437
1526
|
// through redactFindingProvenance — the email is gone before it can
|
|
@@ -1443,6 +1532,9 @@ export function toCLI(scan, { verbose=false, color=true, provenance=false }={}){
|
|
|
1443
1532
|
lines.push('');
|
|
1444
1533
|
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
1445
1534
|
for (const f of findings) counts[f.severity] = (counts[f.severity]||0) + 1;
|
|
1535
|
+
if (findings.some(f => f.verificationRecord)) lines.push(verificationCoverage(findings.map(f => f.verificationRecord).filter(Boolean)).lines[0]);
|
|
1536
|
+
if (findings.some(f => f.boundaryContext)) lines.push(boundaryCoverage(findings.map(f => f.boundaryContext).filter(Boolean)).lines[0]);
|
|
1537
|
+
{ const ic = invariantCoverageFields(scan.invariantCoverage); if (ic.invariantCoverage) lines.push(...ic.invariantCoverage.lines); }
|
|
1446
1538
|
lines.push(`${c('Critical:', SEV_COLOR.critical)} ${counts.critical} ${c('High:', SEV_COLOR.high)} ${counts.high} ${c('Medium:', SEV_COLOR.medium)} ${counts.medium} ${c('Low:', SEV_COLOR.low)} ${counts.low} ${c('Info:', SEV_COLOR.info)} ${counts.info}`);
|
|
1447
1539
|
return lines.join('\n');
|
|
1448
1540
|
}
|
|
@@ -1567,7 +1659,14 @@ export function toShipVerdict(scan, options = {}) {
|
|
|
1567
1659
|
const clean = actionable.length === 0 && !scanIncomplete;
|
|
1568
1660
|
lines.push(clean ? approveFace({ color }) : alertFace({ color }));
|
|
1569
1661
|
lines.push(bar);
|
|
1570
|
-
|
|
1662
|
+
// X-703.AC03: with the `portfolio-assurance` feature on, the headline is the bounded claim followed by scope and gaps. Off
|
|
1663
|
+
// (the default) the wording below is unchanged, because existing output and documentation pin it.
|
|
1664
|
+
const _bounded = portfolioAssuranceEnabled({ option: options.portfolioAssurance, scanRoot: options.scanRoot });
|
|
1665
|
+
if (_bounded) {
|
|
1666
|
+
const w = scanVerdictWording({ clean, scanIncomplete: !!scanIncomplete, actionableCount: actionable.length });
|
|
1667
|
+
lines.push(c(` ${w.icon} ${w.headline}`, w.tier === 'ok' ? SEV_COLOR.low + BOLD : w.tier === 'incomplete' ? SEV_COLOR.high + BOLD : SEV_COLOR.critical + BOLD));
|
|
1668
|
+
lines.push(c(` Scope: completed supported checks only${scanIncomplete ? '; gaps listed below' : '; no gaps recorded by scan health'}. ${NOT_A_GUARANTEE}`, DIM));
|
|
1669
|
+
} else if (clean) {
|
|
1571
1670
|
lines.push(c(' ✅ Safe to deploy', SEV_COLOR.low + BOLD));
|
|
1572
1671
|
} else if (actionable.length === 0) {
|
|
1573
1672
|
lines.push(c(' ⚠️ Scan incomplete — cannot confirm safe to deploy', SEV_COLOR.high + BOLD));
|
package/src/sandbox/CLAUDE.md
CHANGED
|
@@ -347,6 +347,57 @@ its payload ran — not an escape (the prelude runs inside the confinement) but
|
|
|
347
347
|
a way for a config-derived value to silently *disable* the limits it was
|
|
348
348
|
supposed to set.
|
|
349
349
|
|
|
350
|
+
## Trust boundary (CORE-003)
|
|
351
|
+
|
|
352
|
+
Four trust domains (target, worker, verifier/custodian, signer) are defined in
|
|
353
|
+
`trust-domains.js` as a default-deny policy plus pure helpers (protected host
|
|
354
|
+
paths, secret-env detection, verification-status formation). The ENFORCEMENT is
|
|
355
|
+
`trust-boundary.js` `runInBoundary()`, the one function verifier-side code uses
|
|
356
|
+
to run untrusted code. It: refuses secret-looking env for the target; refuses a
|
|
357
|
+
workspace overlapping a protected path; probes the backend's controls
|
|
358
|
+
(`control-probes.js`) and returns `status:'blocked'` (target never executed,
|
|
359
|
+
`verificationStatus:'not-run'`) when any required control is not `proved`;
|
|
360
|
+
then runs supervised with protected paths read-denied.
|
|
361
|
+
|
|
362
|
+
- **Read denial** is the new control `denyReadPaths` (userspace backend: SBPL
|
|
363
|
+
deny rules after the blanket read allow, paths passed as parameters). The
|
|
364
|
+
default protected set is the key directory, common credential directories
|
|
365
|
+
under the home directory, plus caller-supplied sealed-label and evidence
|
|
366
|
+
directories. A denylist is a floor, not a claim of host-wide read isolation.
|
|
367
|
+
The namespace backend does NOT implement it and returns `status:'error'`
|
|
368
|
+
without executing when asked, so on Linux the boundary is `blocked`.
|
|
369
|
+
- **Process trees** (`supervise.js`): own process group, periodic descendant
|
|
370
|
+
sweep, SIGTERM, grace, SIGKILL, then a poll that confirms no survivors; the
|
|
371
|
+
same cleanup runs on a clean exit. Cancel is an `AbortSignal` (status
|
|
372
|
+
`cancelled`). Output is capped. Known gap: a double-fork plus setsid between
|
|
373
|
+
two sweeps escapes; only a PID namespace or cgroup closes it. Supervised
|
|
374
|
+
execution exists ONLY for the userspace backend; `namespace` is refused
|
|
375
|
+
because tree termination has not been implemented and executed there.
|
|
376
|
+
- **Active probes**: write confinement, read denial, env scrub, network, tree
|
|
377
|
+
termination and the file-size limit each run an attack plus a positive
|
|
378
|
+
control through the real backend. `process-cap` is reported `unverified` on
|
|
379
|
+
every backend and must not be claimed enforced (per-uid and system-wide on
|
|
380
|
+
macOS, and it did not refuse on the hosted Linux runner in the last release).
|
|
381
|
+
`capabilityReport()` carries the standing note that Linux enforcement is
|
|
382
|
+
evidenced only where the `sandbox-linux` CI job ran the same probes.
|
|
383
|
+
- **Status**: target output is returned labelled `untrusted` and is never parsed
|
|
384
|
+
for a status. `classifyRun` can yield at most `inconclusive`; `confirmed`
|
|
385
|
+
and `refuted` come only from `settleVerification` given evidence with
|
|
386
|
+
`observedBy: 'verifier'`. Oracle adapters (X-202) must consume this.
|
|
387
|
+
- **Consumer**: the oracle adapters (`posture/oracles/oracle.js`, X-202) run every target through `runInBoundary` and nowhere else, and replay (`posture/replay/replay.js`, X-203) goes through them. A `blocked` boundary is reported by the oracle runner as `unsupported` (an unmet isolation prerequisite), never as a pass or a failure of the hypothesis.
|
|
388
|
+
- Tests: `test/trust-boundary.test.js` (attack fixture, tree kill with `ps`
|
|
389
|
+
orphan checks, blocked-never-pass), in `test:lifecycle`.
|
|
390
|
+
|
|
391
|
+
## Capability mode (X-502, X-504)
|
|
392
|
+
|
|
393
|
+
`backend-userspace.js` has an opt-in mode used by `capabilities/runner.js`; every pre-existing caller passes none of these options and gets the profile it always had.
|
|
394
|
+
|
|
395
|
+
- `readRoots` (array): reads are confined to a fixed baseline of system paths (loader, libraries, locale, timezone, device nodes), the sandbox root, the write roots and these roots. File metadata is granted only for the ancestors of those roots, so a path outside them cannot be probed for existence. `denyReadPaths` still apply last.
|
|
396
|
+
- `writeRoots`: further writable subtrees (readable too). `cwd`: working directory, which must exist.
|
|
397
|
+
- `networkProxyPort`: opens exactly one loopback port (`(remote ip "localhost:PORT")`); DNS, every other port and every other address stay denied.
|
|
398
|
+
|
|
399
|
+
Verified by execution on macOS only (`test/capabilities/`, probes in `capabilities/probes.js`). The namespace backend implements none of these options; asked for them it ignores them, which is why the capability runner never asks it: its probes report these controls `unsupported` there and the run is blocked.
|
|
400
|
+
|
|
350
401
|
## Extending this module
|
|
351
402
|
|
|
352
403
|
- Both real backends (`backend-userspace.js`, `backend-namespace.js`) must
|
|
@@ -244,8 +244,15 @@ export function runNamespace(argv, {
|
|
|
244
244
|
limits = {},
|
|
245
245
|
env = {},
|
|
246
246
|
maxBuffer = 8 * 1024 * 1024,
|
|
247
|
+
denyReadPaths = [],
|
|
247
248
|
} = {}) {
|
|
248
249
|
// Documented shape, never a throw — see the same note in backend-userspace.
|
|
250
|
+
// Read denial is NOT implemented on this backend (its mount tree is rebound
|
|
251
|
+
// read-only, not detached). A caller that asks for it must get a refusal,
|
|
252
|
+
// never a run that silently leaves the path readable.
|
|
253
|
+
if (Array.isArray(denyReadPaths) && denyReadPaths.length) {
|
|
254
|
+
return errorResult('namespace', 'read denial of host paths is not implemented on this backend; refusing to execute');
|
|
255
|
+
}
|
|
249
256
|
if (!root) return errorResult('namespace', 'runNamespace requires a sandbox root');
|
|
250
257
|
|
|
251
258
|
const bin = resolveNamespaceBin();
|
|
@@ -13,75 +13,192 @@
|
|
|
13
13
|
// running. Callers that need a hard tree kill must supply it themselves.
|
|
14
14
|
import { spawnSync } from 'node:child_process';
|
|
15
15
|
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
16
17
|
import { resolveUserspaceBin } from './capabilities.js';
|
|
17
18
|
import { buildLimitPrelude, ambientRelativeMaxProcs } from './limits.js';
|
|
18
19
|
import { buildResult, errorResult, buildConfinedEnv } from './result.js';
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
// Host paths every confined process needs just to start (dynamic loader, system
|
|
22
|
+
// libraries, locale and timezone data, device nodes). Used ONLY when reads are
|
|
23
|
+
// confined; the default profile below still allows reads globally. Metadata
|
|
24
|
+
// (stat) is granted on the ancestors of each readable root and nowhere else, so
|
|
25
|
+
// a path outside the readable set cannot even be probed for existence.
|
|
26
|
+
const BASELINE_READ_SUBPATHS = Object.freeze([
|
|
27
|
+
'/usr', '/bin', '/sbin', '/System', '/Library/Preferences', '/private/etc', '/dev',
|
|
28
|
+
'/private/var/db/dyld', '/private/var/db/timezone', '/private/var/select',
|
|
29
|
+
]);
|
|
30
|
+
const BASELINE_META_LITERALS = Object.freeze([
|
|
31
|
+
'/', '/private', '/private/var', '/private/var/db', '/var', '/etc', '/tmp', '/Library', '/private/tmp',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function _profile({ allowNetwork, proxyPort = null, denyRead = [], confineReads = false, readRoots = [], writeRoots = [], metaLiterals = [] }) {
|
|
35
|
+
const lines = [
|
|
22
36
|
'(version 1)',
|
|
23
37
|
'(deny default)',
|
|
24
38
|
'(allow process-exec process-fork)',
|
|
25
39
|
'(allow sysctl-read)',
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
40
|
+
];
|
|
41
|
+
if (!confineReads) {
|
|
42
|
+
lines.push('(allow file-read*)');
|
|
43
|
+
} else {
|
|
44
|
+
// Reads are confined to the baseline above, the declared readable roots and
|
|
45
|
+
// the writable roots. Every path is a parameter or a fixed literal, never
|
|
46
|
+
// spliced text, so a path cannot inject policy.
|
|
47
|
+
lines.push(`(allow file-read* (literal "/") ${BASELINE_READ_SUBPATHS.map((p) => `(subpath "${p}")`).join(' ')})`);
|
|
48
|
+
lines.push(`(allow file-read-metadata ${BASELINE_META_LITERALS.map((p) => `(literal "${p}")`).join(' ')})`);
|
|
49
|
+
lines.push('(allow file-read* (subpath (param "ROOT")))');
|
|
50
|
+
readRoots.forEach((_, i) => lines.push(`(allow file-read* (subpath (param "RD${i}")))`));
|
|
51
|
+
writeRoots.forEach((_, i) => lines.push(`(allow file-read* (subpath (param "WR${i}")))`));
|
|
52
|
+
metaLiterals.forEach((_, i) => lines.push(`(allow file-read-metadata (literal (param "META${i}")))`));
|
|
53
|
+
lines.push('(allow file-write* (literal "/dev/null"))');
|
|
54
|
+
}
|
|
55
|
+
// Read denial (CORE-003). SBPL is last-match-wins, so these deny rules MUST
|
|
56
|
+
// come after every read allow.
|
|
57
|
+
denyRead.forEach((_, i) => lines.push(`(deny file-read* (subpath (param "DENY${i}")))`));
|
|
58
|
+
lines.push('(allow file-write* (subpath (param "ROOT")))');
|
|
59
|
+
writeRoots.forEach((_, i) => lines.push(`(allow file-write* (subpath (param "WR${i}")))`));
|
|
60
|
+
if (allowNetwork) lines.push('(allow network*)');
|
|
61
|
+
// Mediated network (X-504): the only reachable endpoint is the runner's own
|
|
62
|
+
// loopback proxy port; everything else, including every other loopback port
|
|
63
|
+
// and DNS, stays denied by the default rule.
|
|
64
|
+
else if (Number.isInteger(proxyPort) && proxyPort > 0 && proxyPort < 65536) {
|
|
65
|
+
lines.push(`(allow network-outbound (remote ip "localhost:${proxyPort}"))`);
|
|
66
|
+
}
|
|
67
|
+
return lines.join('\n');
|
|
30
68
|
}
|
|
31
69
|
|
|
32
|
-
|
|
70
|
+
function _realOrResolved(p) {
|
|
71
|
+
let real = path.resolve(p);
|
|
72
|
+
try { real = fs.realpathSync(real); } catch { /* not present: use the literal path */ }
|
|
73
|
+
return real;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _ancestors(p) {
|
|
77
|
+
const out = [];
|
|
78
|
+
let cur = path.dirname(p);
|
|
79
|
+
while (cur && cur !== path.dirname(cur)) { out.push(cur); cur = path.dirname(cur); }
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the exact spawn invocation for a userspace-confined command, without
|
|
85
|
+
* running it. Shared by the synchronous runner below and the supervised
|
|
86
|
+
* (process-tree-killing) runner, so both are confined by the SAME profile.
|
|
87
|
+
* Returns `{ error }` in the documented error shape instead of throwing.
|
|
88
|
+
*/
|
|
89
|
+
export function buildUserspaceInvocation(argv, {
|
|
33
90
|
root,
|
|
34
|
-
timeoutMs = 10000,
|
|
35
91
|
allowNetwork = false,
|
|
36
92
|
limits = {},
|
|
37
93
|
env = {},
|
|
38
|
-
|
|
94
|
+
denyReadPaths = [],
|
|
95
|
+
readRoots = null,
|
|
96
|
+
writeRoots = [],
|
|
97
|
+
networkProxyPort = null,
|
|
98
|
+
cwd = null,
|
|
39
99
|
} = {}) {
|
|
40
|
-
|
|
41
|
-
// "falls back" is a classic route to unconfined execution.
|
|
42
|
-
if (!root) return errorResult('userspace', 'runUserspace requires a sandbox root');
|
|
100
|
+
if (!root) return { error: errorResult('userspace', 'runUserspace requires a sandbox root') };
|
|
43
101
|
|
|
44
102
|
const bin = resolveUserspaceBin();
|
|
45
|
-
if (!bin) return errorResult('userspace', 'no userspace confinement binary found on this host');
|
|
103
|
+
if (!bin) return { error: errorResult('userspace', 'no userspace confinement binary found on this host') };
|
|
46
104
|
|
|
47
105
|
let resolvedRoot;
|
|
48
106
|
try {
|
|
49
|
-
// Resolve symlinks (e.g. macOS /var -> /private/var) so the profile's
|
|
50
|
-
// subpath param matches the path the kernel actually sees.
|
|
51
107
|
resolvedRoot = fs.realpathSync(root);
|
|
52
108
|
} catch (e) {
|
|
53
|
-
return errorResult('userspace', `sandbox root is not usable: ${e.message}`);
|
|
109
|
+
return { error: errorResult('userspace', `sandbox root is not usable: ${e.message}`) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Resolve each denied path as far as it exists (a symlink would otherwise
|
|
113
|
+
// let the same file be reached by a path the profile does not name). A path
|
|
114
|
+
// that does not exist yet is denied by its literal text.
|
|
115
|
+
const denyRead = [];
|
|
116
|
+
for (const p of denyReadPaths || []) {
|
|
117
|
+
if (typeof p !== 'string' || !p) continue;
|
|
118
|
+
let real = path.resolve(p);
|
|
119
|
+
try { real = fs.realpathSync(real); } catch { /* not present: deny the literal path */ }
|
|
120
|
+
if (!denyRead.includes(real)) denyRead.push(real);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Capability mode (X-502/X-504). `readRoots` non-null confines reads to the
|
|
124
|
+
// baseline plus those roots; `writeRoots` adds writable subtrees beyond
|
|
125
|
+
// `root`; `networkProxyPort` opens exactly one loopback port. All of them are
|
|
126
|
+
// absent for every pre-existing caller, whose profile is unchanged.
|
|
127
|
+
const confineReads = Array.isArray(readRoots);
|
|
128
|
+
const rd = confineReads ? [...new Set(readRoots.filter((p) => typeof p === 'string' && p).map(_realOrResolved))] : [];
|
|
129
|
+
const wr = [...new Set((writeRoots || []).filter((p) => typeof p === 'string' && p).map(_realOrResolved))];
|
|
130
|
+
let resolvedCwd = resolvedRoot;
|
|
131
|
+
if (cwd) {
|
|
132
|
+
resolvedCwd = _realOrResolved(cwd);
|
|
133
|
+
try { if (!fs.statSync(resolvedCwd).isDirectory()) throw new Error('not a directory'); } catch (e) {
|
|
134
|
+
return { error: errorResult('userspace', `working directory is not usable: ${e.message}`) };
|
|
135
|
+
}
|
|
54
136
|
}
|
|
137
|
+
const metaSet = new Set();
|
|
138
|
+
if (confineReads) {
|
|
139
|
+
for (const p of [resolvedRoot, resolvedCwd, ...rd, ...wr]) for (const a of _ancestors(p)) metaSet.add(a);
|
|
140
|
+
for (const b of BASELINE_META_LITERALS) metaSet.delete(b);
|
|
141
|
+
}
|
|
142
|
+
const metaLiterals = [...metaSet].sort();
|
|
55
143
|
|
|
56
144
|
const effectiveLimits = {
|
|
57
145
|
...limits,
|
|
58
146
|
maxProcs: limits.maxProcs ?? ambientRelativeMaxProcs(),
|
|
59
147
|
};
|
|
60
|
-
|
|
61
148
|
let prelude, unsupported;
|
|
62
149
|
try {
|
|
63
150
|
({ prelude, unsupported } = buildLimitPrelude(effectiveLimits));
|
|
64
151
|
} catch (e) {
|
|
65
|
-
return errorResult('userspace', `invalid resource limit: ${e.message}`);
|
|
152
|
+
return { error: errorResult('userspace', `invalid resource limit: ${e.message}`) };
|
|
66
153
|
}
|
|
67
154
|
const inner = `${prelude}exec "$@"`;
|
|
68
155
|
|
|
69
|
-
|
|
156
|
+
return {
|
|
70
157
|
bin,
|
|
71
|
-
[
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
158
|
+
args: [
|
|
159
|
+
'-p', _profile({ allowNetwork, proxyPort: networkProxyPort, denyRead, confineReads, readRoots: rd, writeRoots: wr, metaLiterals }),
|
|
160
|
+
'-D', `ROOT=${resolvedRoot}`,
|
|
161
|
+
...denyRead.flatMap((p, i) => ['-D', `DENY${i}=${p}`]),
|
|
162
|
+
...rd.flatMap((p, i) => ['-D', `RD${i}=${p}`]),
|
|
163
|
+
...wr.flatMap((p, i) => ['-D', `WR${i}=${p}`]),
|
|
164
|
+
...metaLiterals.flatMap((p, i) => ['-D', `META${i}=${p}`]),
|
|
165
|
+
'/bin/sh', '-c', inner, '_sbx', ...argv,
|
|
166
|
+
],
|
|
167
|
+
cwd: resolvedCwd,
|
|
168
|
+
env: buildConfinedEnv({ root: resolvedRoot, env }),
|
|
169
|
+
unsupported,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function runUserspace(argv, {
|
|
174
|
+
root,
|
|
175
|
+
timeoutMs = 10000,
|
|
176
|
+
allowNetwork = false,
|
|
177
|
+
limits = {},
|
|
178
|
+
env = {},
|
|
179
|
+
maxBuffer = 8 * 1024 * 1024,
|
|
180
|
+
denyReadPaths = [],
|
|
181
|
+
readRoots = null,
|
|
182
|
+
writeRoots = [],
|
|
183
|
+
networkProxyPort = null,
|
|
184
|
+
cwd = null,
|
|
185
|
+
} = {}) {
|
|
186
|
+
// Documented shape, never a throw: a caller that wraps this in try/catch and
|
|
187
|
+
// "falls back" is a classic route to unconfined execution.
|
|
188
|
+
const inv = buildUserspaceInvocation(argv, { root, allowNetwork, limits, env, denyReadPaths, readRoots, writeRoots, networkProxyPort, cwd });
|
|
189
|
+
if (inv.error) return inv.error;
|
|
190
|
+
|
|
191
|
+
const r = spawnSync(inv.bin, inv.args, {
|
|
192
|
+
encoding: 'utf8',
|
|
193
|
+
timeout: timeoutMs,
|
|
194
|
+
// Match the namespace backend: SIGKILL cannot be ignored, SIGTERM can.
|
|
195
|
+
// A payload that installs a SIGTERM handler would otherwise outlive its
|
|
196
|
+
// own budget while the caller is told it timed out.
|
|
197
|
+
killSignal: 'SIGKILL',
|
|
198
|
+
maxBuffer,
|
|
199
|
+
cwd: inv.cwd,
|
|
200
|
+
env: inv.env,
|
|
201
|
+
});
|
|
85
202
|
|
|
86
|
-
return buildResult({ backend: 'userspace', spawnResult: r, unsupported });
|
|
203
|
+
return buildResult({ backend: 'userspace', spawnResult: r, unsupported: inv.unsupported });
|
|
87
204
|
}
|