@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.1
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 +34 -0
- package/bin/agentic-security.js +87 -0
- 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 +1 -1
- 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 +1 -1
- package/dist/9560.index.js +2318 -23
- package/dist/957.index.js +1 -1
- package/dist/9999.index.js +43 -7
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +33 -6
- 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 +30 -0
- package/src/dataflow/catalog.js +42 -1
- package/src/dataflow/engine.js +67 -2
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +15 -1
- package/src/discovery/lenses.js +18 -0
- package/src/engine.js +80 -15
- 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 +5 -2
- 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 +13 -1
- package/src/posture/CLAUDE.md +332 -1
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- 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/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 +172 -1
- package/src/posture/oracles/conformance.js +3 -2
- package/src/posture/oracles/oracle.js +23 -3
- package/src/posture/oracles/registry.js +1 -1
- 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 +1 -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/patch-negative.js +16 -4
- package/src/posture/verification/patch-promotion.js +10 -2
- package/src/report/index.js +51 -1
- package/src/sandbox/CLAUDE.md +10 -0
- package/src/sandbox/backend-userspace.js +91 -13
- package/dist/5613.index.js +0 -500
- package/dist/6239.index.js +0 -469
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
// environment-mismatch the runs were not in equivalent environments
|
|
28
28
|
// promotion-refused the receipts do not match the exact proposed diff and revision
|
|
29
29
|
//
|
|
30
|
+
// The behaviour check is the functional-regression oracle by default. A requester may name `business-state` as `functional.oracleId`
|
|
31
|
+
// (X-406, `invariants/repair.js`): the same two runs and the same expectation, with an authorized-workflow scenario as the declared
|
|
32
|
+
// case, and `promotePatch` then requires the functional receipts to come from that oracle class instead.
|
|
33
|
+
//
|
|
30
34
|
// Scope, stated so no caller overclaims: the exploit scenario and the functional cases are the REQUESTER's; this verifies the
|
|
31
35
|
// declared scenario and cases, not that the patch is correct elsewhere. Linux is not claimed (the oracle platform statements
|
|
32
36
|
// stay `unverified`). Receipts are valid inside this process only (see patch-promotion.js).
|
|
@@ -40,6 +44,9 @@ import { appendRepairRecord, recordPromotion, verificationRepairFor, withRepairS
|
|
|
40
44
|
|
|
41
45
|
const PATCH_NEGATIVE_FEATURE = 'patch-negative-verification';
|
|
42
46
|
const FUNCTIONAL_ORACLE = 'functional-regression';
|
|
47
|
+
// The check that behaviour is preserved is normally the functional-regression oracle. A requester may name `business-state`
|
|
48
|
+
// instead (X-406): the same two runs, the same expectation (refuted), with an authorized-workflow scenario as the declared case.
|
|
49
|
+
const FUNCTIONAL_ORACLES = Object.freeze([FUNCTIONAL_ORACLE, 'business-state']);
|
|
43
50
|
|
|
44
51
|
const STEPS = Object.freeze(['original-positive', 'patched-negative', 'functional-baseline', 'functional-regression']);
|
|
45
52
|
|
|
@@ -64,7 +71,8 @@ function classify(step, run) {
|
|
|
64
71
|
}
|
|
65
72
|
const outcome = run.outcome;
|
|
66
73
|
const observed = run.receipt?.observed || {};
|
|
67
|
-
|
|
74
|
+
// a business-state run reports its control flow under `preconditions`: a control flow that no longer works is a broken revision
|
|
75
|
+
const loadBroken = observed.targetLoaded === false || observed.benignCompleted === false || observed.preconditions?.controlRan === false || observed.preconditions?.controlDurableEffect === false;
|
|
68
76
|
if (outcome === 'error') return { status: 'incomplete', code: 'harness-error', reason: String(run.record?.reason || 'the oracle run failed').slice(0, 300), outcome };
|
|
69
77
|
if (outcome === EXPECTED[step]) return { status: 'passed', code: null, reason: run.record?.reason ?? null, outcome };
|
|
70
78
|
const reason = String(run.record?.reason || '').slice(0, 300);
|
|
@@ -150,6 +158,10 @@ export async function verifyPatchNegative(req, o = {}) {
|
|
|
150
158
|
if (!isPlainObject(fn) || !isPlainObject(fn.inputs)) {
|
|
151
159
|
return refuse('functional-regression', 'functional-omitted', 'no functional regression check was supplied: a patch that was never checked for behaviour cannot be a verified fix');
|
|
152
160
|
}
|
|
161
|
+
const functionalOracle = fn.oracleId === undefined ? FUNCTIONAL_ORACLE : fn.oracleId;
|
|
162
|
+
if (!FUNCTIONAL_ORACLES.includes(functionalOracle)) {
|
|
163
|
+
return refuse('input-validation', 'bad-request', `the functional check oracle must be one of ${FUNCTIONAL_ORACLES.join(', ')}`);
|
|
164
|
+
}
|
|
153
165
|
|
|
154
166
|
const patchDigest = digestOf(patchFiles);
|
|
155
167
|
base.patchDigest = patchDigest;
|
|
@@ -158,8 +170,8 @@ export async function verifyPatchNegative(req, o = {}) {
|
|
|
158
170
|
const plan = [
|
|
159
171
|
['original-positive', { oracleId: original.oracleId, entry: original.entry, inputs: original.inputs, patchFiles: null, budgets: original.budgets }],
|
|
160
172
|
['patched-negative', { oracleId: original.oracleId, entry: original.entry, inputs: original.inputs, patchFiles, budgets: original.budgets }],
|
|
161
|
-
['functional-baseline', { oracleId:
|
|
162
|
-
['functional-regression', { oracleId:
|
|
173
|
+
['functional-baseline', { oracleId: functionalOracle, entry: original.entry, inputs: fn.inputs, patchFiles: null, budgets: fn.budgets }],
|
|
174
|
+
['functional-regression', { oracleId: functionalOracle, entry: original.entry, inputs: fn.inputs, patchFiles, budgets: fn.budgets }],
|
|
163
175
|
];
|
|
164
176
|
const runs = {};
|
|
165
177
|
let failed = null;
|
|
@@ -186,7 +198,7 @@ export async function verifyPatchNegative(req, o = {}) {
|
|
|
186
198
|
|
|
187
199
|
// ---- the receipts must match the exact proposed diff and revision before anything is promoted
|
|
188
200
|
const receipts = Object.fromEntries(Object.entries(runs).map(([k, r]) => [k, r.receipt]));
|
|
189
|
-
const promotion = promotePatch({ proposal: { hypothesisId: req.hypothesisId, revision: req.commit, originalFiles: original.files, patchFiles }, receipts });
|
|
201
|
+
const promotion = promotePatch({ proposal: { hypothesisId: req.hypothesisId, revision: req.commit, originalFiles: original.files, patchFiles, functionalOracle }, receipts });
|
|
190
202
|
if (!promotion.ok) {
|
|
191
203
|
const envBroken = promotion.reasons.some((r) => r.code === 'environment-mismatch');
|
|
192
204
|
const code = envBroken ? 'environment-mismatch' : promotion.reasons.some((r) => r.code === 'oracle-changed') ? 'oracle-changed' : 'promotion-refused';
|
|
@@ -23,6 +23,9 @@ import { isCommit, isPlainObject } from '../assurance/schema-kit.js';
|
|
|
23
23
|
import { isIssuedReceipt } from '../oracles/oracle.js';
|
|
24
24
|
|
|
25
25
|
const PROMOTION_ROLES = Object.freeze(['original', 'patched', 'functionalBaseline', 'functional']);
|
|
26
|
+
// The oracle class the behaviour-preservation runs must come from. Default `functional-regression`; a proposal may name
|
|
27
|
+
// `business-state` (X-406: an authorized-workflow scenario as the declared case). Nothing else is accepted.
|
|
28
|
+
const FUNCTIONAL_CLASSES = Object.freeze({ 'functional-regression': 'functional-regression', 'business-state': 'business-state' });
|
|
26
29
|
|
|
27
30
|
const ISSUED_PROMOTIONS = new WeakSet();
|
|
28
31
|
|
|
@@ -36,7 +39,7 @@ const same = (a, b) => digestOf(a ?? null) === digestOf(b ?? null);
|
|
|
36
39
|
|
|
37
40
|
/**
|
|
38
41
|
* @param {object} args
|
|
39
|
-
* @param {object} args.proposal { hypothesisId, revision, originalFiles, patchFiles }
|
|
42
|
+
* @param {object} args.proposal { hypothesisId, revision, originalFiles, patchFiles, functionalOracle? }
|
|
40
43
|
* @param {object} args.receipts { original, patched, functionalBaseline, functional }, each issued by runOracle
|
|
41
44
|
* @returns {{ ok: boolean, diffDigest: string|null, revision: string|null, reasons: {code,role,message}[], receiptDigests: object }}
|
|
42
45
|
*/
|
|
@@ -59,6 +62,11 @@ export function promotePatch({ proposal, receipts } = {}) {
|
|
|
59
62
|
why('revision-unbound', null, 'a promotion is bound to one exact 40 or 64 character commit; none was given');
|
|
60
63
|
return out();
|
|
61
64
|
}
|
|
65
|
+
const functionalClass = proposal.functionalOracle === undefined ? 'functional-regression' : FUNCTIONAL_CLASSES[proposal.functionalOracle];
|
|
66
|
+
if (!functionalClass) {
|
|
67
|
+
why('bad-proposal', null, 'the functional check oracle must be functional-regression or business-state');
|
|
68
|
+
return out();
|
|
69
|
+
}
|
|
62
70
|
const diffDigest = digestOf(proposal.patchFiles);
|
|
63
71
|
const originalDigest = digestOf(proposal.originalFiles);
|
|
64
72
|
const patchedDigest = digestOf({ ...proposal.originalFiles, ...proposal.patchFiles });
|
|
@@ -92,7 +100,7 @@ export function promotePatch({ proposal, receipts } = {}) {
|
|
|
92
100
|
if (!same(functionalBaseline.oracle, functional.oracle) || functionalBaseline.request.inputsDigest !== functional.request.inputsDigest) {
|
|
93
101
|
why('oracle-changed', 'functional', 'the functional check on the patched revision did not use the same oracle and cases as its baseline');
|
|
94
102
|
}
|
|
95
|
-
if (functional.oracle.class !==
|
|
103
|
+
if (functional.oracle.class !== functionalClass) why('oracle-changed', 'functional', `the functional check is not a ${functionalClass} oracle`);
|
|
96
104
|
if (original.oracle.class === 'functional-regression') why('oracle-changed', 'original', 'the exploit oracle must not be the functional-regression oracle');
|
|
97
105
|
// equivalent disposable environments: one sanitized environment identity across every run
|
|
98
106
|
const envDigest = digestOf(original.environment);
|
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`
|
|
@@ -14,8 +15,11 @@ import { applyLegacyCompat, legacyFieldDeprecationNotice } from '../pipeline/leg
|
|
|
14
15
|
import { redactFindingProvenance, sanitizeForTerminal } from '../posture/provenance/schema.js';
|
|
15
16
|
// X-206: the one projection of the verification record. This report, the MCP tools and the autopilot response all call it.
|
|
16
17
|
import { verificationFields, verificationCoverage } from '../posture/verification/projection.js';
|
|
18
|
+
import { invariantCoverageFields } from '../posture/invariants/coverage.js';
|
|
17
19
|
// X-207: a hunt hypothesis is advisory and is never a finding in any gating output; promotion is the only way across.
|
|
18
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';
|
|
19
23
|
// Re-exported: FR-PROV-026. Lives in provenance/schema.js (shared with
|
|
20
24
|
// posture/auditor-walkthrough.js, a second CLI renderer of the same
|
|
21
25
|
// untrusted fields — see that module's header for why it lives there
|
|
@@ -341,6 +345,11 @@ export function normalizeFindings(scan){
|
|
|
341
345
|
...(f.verificationRecord && typeof f.verificationRecord === 'object'
|
|
342
346
|
? { verificationRecord: f.verificationRecord, ...(f.verificationReplay ? { verificationReplay: f.verificationReplay } : {}), ...verificationFields(f.verificationRecord, { replay: f.verificationReplay }) }
|
|
343
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
|
+
: {}),
|
|
344
353
|
// Phase-1 next-gen P1.3 (FR-UX-1, FR-UX-2): calibrated probability +
|
|
345
354
|
// 95% Wilson CI + sample size. Null when N < MIN_SAMPLES_FOR_CALIBRATION
|
|
346
355
|
// for this family; `calibration_reason` explains why.
|
|
@@ -753,6 +762,10 @@ export function toJSON(scan, meta={}, opts={}){
|
|
|
753
762
|
// X-206: trusted-negative accounting over the findings that carry a verification record. Absent when none does, so a plain
|
|
754
763
|
// scan's output is unchanged.
|
|
755
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),
|
|
756
769
|
};
|
|
757
770
|
if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
|
|
758
771
|
return out;
|
|
@@ -932,6 +945,15 @@ export function verificationBlock(f) {
|
|
|
932
945
|
return Array.isArray(text) && text.length ? text.map((l) => String(l).match(/^ */)[0] + sanitizeForTerminal(String(l))) : null;
|
|
933
946
|
}
|
|
934
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
|
+
|
|
935
957
|
export function toMarkdown(scan, meta={}){
|
|
936
958
|
const findings = normalizeFindings(scan);
|
|
937
959
|
const lines = ['# Agentic Security — Scan Report', ''];
|
|
@@ -981,6 +1003,23 @@ export function toMarkdown(scan, meta={}){
|
|
|
981
1003
|
}
|
|
982
1004
|
lines.push('</details>');
|
|
983
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
|
+
}
|
|
984
1023
|
// FR-PROV-018: one provenance block per finding that has one, reusing
|
|
985
1024
|
// explainProvenance's content — never a second, divergent renderer.
|
|
986
1025
|
const withProvenance = bySev[sev].filter(f => f.findingProvenance);
|
|
@@ -1480,6 +1519,8 @@ export function toCLI(scan, { verbose=false, color=true, provenance=false }={}){
|
|
|
1480
1519
|
if (ex.fixCode) for (const ln of ex.fixCode.split('\n').slice(0, 6)) lines.push(` ${c(ln, DIM)}`);
|
|
1481
1520
|
const vblock = verificationBlock(f);
|
|
1482
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)}`);
|
|
1483
1524
|
if (provenance) {
|
|
1484
1525
|
// `f` here is already normalized, so its findingProvenance has been
|
|
1485
1526
|
// through redactFindingProvenance — the email is gone before it can
|
|
@@ -1492,6 +1533,8 @@ export function toCLI(scan, { verbose=false, color=true, provenance=false }={}){
|
|
|
1492
1533
|
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
1493
1534
|
for (const f of findings) counts[f.severity] = (counts[f.severity]||0) + 1;
|
|
1494
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); }
|
|
1495
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}`);
|
|
1496
1539
|
return lines.join('\n');
|
|
1497
1540
|
}
|
|
@@ -1616,7 +1659,14 @@ export function toShipVerdict(scan, options = {}) {
|
|
|
1616
1659
|
const clean = actionable.length === 0 && !scanIncomplete;
|
|
1617
1660
|
lines.push(clean ? approveFace({ color }) : alertFace({ color }));
|
|
1618
1661
|
lines.push(bar);
|
|
1619
|
-
|
|
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) {
|
|
1620
1670
|
lines.push(c(' ✅ Safe to deploy', SEV_COLOR.low + BOLD));
|
|
1621
1671
|
} else if (actionable.length === 0) {
|
|
1622
1672
|
lines.push(c(' ⚠️ Scan incomplete — cannot confirm safe to deploy', SEV_COLOR.high + BOLD));
|
package/src/sandbox/CLAUDE.md
CHANGED
|
@@ -388,6 +388,16 @@ then runs supervised with protected paths read-denied.
|
|
|
388
388
|
- Tests: `test/trust-boundary.test.js` (attack fixture, tree kill with `ps`
|
|
389
389
|
orphan checks, blocked-never-pass), in `test:lifecycle`.
|
|
390
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
|
+
|
|
391
401
|
## Extending this module
|
|
392
402
|
|
|
393
403
|
- Both real backends (`backend-userspace.js`, `backend-namespace.js`) must
|
|
@@ -18,20 +18,66 @@ import { resolveUserspaceBin } from './capabilities.js';
|
|
|
18
18
|
import { buildLimitPrelude, ambientRelativeMaxProcs } from './limits.js';
|
|
19
19
|
import { buildResult, errorResult, buildConfinedEnv } from './result.js';
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
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 = [
|
|
23
36
|
'(version 1)',
|
|
24
37
|
'(deny default)',
|
|
25
38
|
'(allow process-exec process-fork)',
|
|
26
39
|
'(allow sysctl-read)',
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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');
|
|
68
|
+
}
|
|
69
|
+
|
|
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;
|
|
35
81
|
}
|
|
36
82
|
|
|
37
83
|
/**
|
|
@@ -46,6 +92,10 @@ export function buildUserspaceInvocation(argv, {
|
|
|
46
92
|
limits = {},
|
|
47
93
|
env = {},
|
|
48
94
|
denyReadPaths = [],
|
|
95
|
+
readRoots = null,
|
|
96
|
+
writeRoots = [],
|
|
97
|
+
networkProxyPort = null,
|
|
98
|
+
cwd = null,
|
|
49
99
|
} = {}) {
|
|
50
100
|
if (!root) return { error: errorResult('userspace', 'runUserspace requires a sandbox root') };
|
|
51
101
|
|
|
@@ -70,6 +120,27 @@ export function buildUserspaceInvocation(argv, {
|
|
|
70
120
|
if (!denyRead.includes(real)) denyRead.push(real);
|
|
71
121
|
}
|
|
72
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
|
+
}
|
|
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();
|
|
143
|
+
|
|
73
144
|
const effectiveLimits = {
|
|
74
145
|
...limits,
|
|
75
146
|
maxProcs: limits.maxProcs ?? ambientRelativeMaxProcs(),
|
|
@@ -85,12 +156,15 @@ export function buildUserspaceInvocation(argv, {
|
|
|
85
156
|
return {
|
|
86
157
|
bin,
|
|
87
158
|
args: [
|
|
88
|
-
'-p', _profile({ allowNetwork, denyRead }),
|
|
159
|
+
'-p', _profile({ allowNetwork, proxyPort: networkProxyPort, denyRead, confineReads, readRoots: rd, writeRoots: wr, metaLiterals }),
|
|
89
160
|
'-D', `ROOT=${resolvedRoot}`,
|
|
90
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}`]),
|
|
91
165
|
'/bin/sh', '-c', inner, '_sbx', ...argv,
|
|
92
166
|
],
|
|
93
|
-
cwd:
|
|
167
|
+
cwd: resolvedCwd,
|
|
94
168
|
env: buildConfinedEnv({ root: resolvedRoot, env }),
|
|
95
169
|
unsupported,
|
|
96
170
|
};
|
|
@@ -104,10 +178,14 @@ export function runUserspace(argv, {
|
|
|
104
178
|
env = {},
|
|
105
179
|
maxBuffer = 8 * 1024 * 1024,
|
|
106
180
|
denyReadPaths = [],
|
|
181
|
+
readRoots = null,
|
|
182
|
+
writeRoots = [],
|
|
183
|
+
networkProxyPort = null,
|
|
184
|
+
cwd = null,
|
|
107
185
|
} = {}) {
|
|
108
186
|
// Documented shape, never a throw: a caller that wraps this in try/catch and
|
|
109
187
|
// "falls back" is a classic route to unconfined execution.
|
|
110
|
-
const inv = buildUserspaceInvocation(argv, { root, allowNetwork, limits, env, denyReadPaths });
|
|
188
|
+
const inv = buildUserspaceInvocation(argv, { root, allowNetwork, limits, env, denyReadPaths, readRoots, writeRoots, networkProxyPort, cwd });
|
|
111
189
|
if (inv.error) return inv.error;
|
|
112
190
|
|
|
113
191
|
const r = spawnSync(inv.bin, inv.args, {
|