@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
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
|
@@ -12,6 +12,10 @@ import { applyLegacyCompat, legacyFieldDeprecationNotice } from '../pipeline/leg
|
|
|
12
12
|
// (sca/sigstore-verify.js's SLSA/Sigstore build attestation). Three unrelated
|
|
13
13
|
// things, three distinct keys — do not collapse them.
|
|
14
14
|
import { redactFindingProvenance, sanitizeForTerminal } from '../posture/provenance/schema.js';
|
|
15
|
+
// X-206: the one projection of the verification record. This report, the MCP tools and the autopilot response all call it.
|
|
16
|
+
import { verificationFields, verificationCoverage } from '../posture/verification/projection.js';
|
|
17
|
+
// X-207: a hunt hypothesis is advisory and is never a finding in any gating output; promotion is the only way across.
|
|
18
|
+
import { isAdvisoryHypothesis } from '../posture/verification/advisory-state.js';
|
|
15
19
|
// Re-exported: FR-PROV-026. Lives in provenance/schema.js (shared with
|
|
16
20
|
// posture/auditor-walkthrough.js, a second CLI renderer of the same
|
|
17
21
|
// untrusted fields — see that module's header for why it lives there
|
|
@@ -222,7 +226,11 @@ export function normalizeFindings(scan){
|
|
|
222
226
|
});
|
|
223
227
|
return true;
|
|
224
228
|
};
|
|
229
|
+
const advisoryExcluded = [];
|
|
225
230
|
for (const f of (scan.findings||[])) {
|
|
231
|
+
// X-207.AC01: an unpromoted hunt hypothesis cannot gate. It is excluded here, the one place every output format and every
|
|
232
|
+
// exit code derives from, and the exclusion is recorded (never silent) as `advisoryExcluded`.
|
|
233
|
+
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
234
|
if (suppress(f.vuln || f.type, f.file, f.line || f.source?.line || 0, f.snippet)) continue;
|
|
227
235
|
out.push({
|
|
228
236
|
id: f.id || fingerprint(f),
|
|
@@ -328,6 +336,11 @@ export function normalizeFindings(scan){
|
|
|
328
336
|
// a finding with no proof backing simply omits these fields.
|
|
329
337
|
...(f.proofTier !== undefined ? { proofTier: f.proofTier } : {}),
|
|
330
338
|
...(f.proofEvidence !== undefined ? { proofEvidence: f.proofEvidence } : {}),
|
|
339
|
+
// X-206: the version-1 verification record and its shared projection, ADDITIVE and present only when the finding carries
|
|
340
|
+
// a record (a plain scan output is byte-identical). `verificationReplay` is the typed prerequisite list of a replay attempt.
|
|
341
|
+
...(f.verificationRecord && typeof f.verificationRecord === 'object'
|
|
342
|
+
? { verificationRecord: f.verificationRecord, ...(f.verificationReplay ? { verificationReplay: f.verificationReplay } : {}), ...verificationFields(f.verificationRecord, { replay: f.verificationReplay }) }
|
|
343
|
+
: {}),
|
|
331
344
|
// Phase-1 next-gen P1.3 (FR-UX-1, FR-UX-2): calibrated probability +
|
|
332
345
|
// 95% Wilson CI + sample size. Null when N < MIN_SAMPLES_FOR_CALIBRATION
|
|
333
346
|
// for this family; `calibration_reason` explains why.
|
|
@@ -587,6 +600,7 @@ export function normalizeFindings(scan){
|
|
|
587
600
|
// all derive from this one function) — one place, covers every output
|
|
588
601
|
// format, matches this function's own role as "the canonical shape".
|
|
589
602
|
for (const f of out) applyLegacyCompat(f);
|
|
603
|
+
if (advisoryExcluded.length) scan.advisoryExcluded = advisoryExcluded; else delete scan.advisoryExcluded;
|
|
590
604
|
|
|
591
605
|
// Sort by severity tier, then within a tier by EPSS percentile (desc) so that
|
|
592
606
|
// CVEs with active in-the-wild abuse float above theoretical CVEs.
|
|
@@ -734,6 +748,11 @@ export function toJSON(scan, meta={}, opts={}){
|
|
|
734
748
|
// hand-built `scan` object in a test), same convention as the S7 fields
|
|
735
749
|
// above; a real scan always computes it.
|
|
736
750
|
scanHealth: scan.scanHealth || null,
|
|
751
|
+
// X-207: advisory hypotheses a caller put in the finding list; excluded from `findings`, listed here so it is never silent.
|
|
752
|
+
...(Array.isArray(scan.advisoryExcluded) && scan.advisoryExcluded.length ? { advisoryExcluded: scan.advisoryExcluded } : {}),
|
|
753
|
+
// X-206: trusted-negative accounting over the findings that carry a verification record. Absent when none does, so a plain
|
|
754
|
+
// scan's output is unchanged.
|
|
755
|
+
...(findings.some(f => f.verificationRecord) ? { verificationCoverage: verificationCoverage(findings.map(f => f.verificationRecord).filter(Boolean)) } : {}),
|
|
737
756
|
};
|
|
738
757
|
if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
|
|
739
758
|
return out;
|
|
@@ -903,6 +922,16 @@ function _mdFenceLen(text) {
|
|
|
903
922
|
return Math.max(3, ...runs.map(r => r.length + 1));
|
|
904
923
|
}
|
|
905
924
|
|
|
925
|
+
/**
|
|
926
|
+
* X-206: the human-readable verification block for a normalized finding, from the SAME projection the JSON and MCP surfaces
|
|
927
|
+
* carry (`finding.verification.text`). Null when the finding has no verification record. Terminal control characters are stripped.
|
|
928
|
+
*/
|
|
929
|
+
export function verificationBlock(f) {
|
|
930
|
+
const text = f?.verificationView?.text;
|
|
931
|
+
// sanitizeForTerminal trims, so the line's own indentation is restored after it strips control characters.
|
|
932
|
+
return Array.isArray(text) && text.length ? text.map((l) => String(l).match(/^ */)[0] + sanitizeForTerminal(String(l))) : null;
|
|
933
|
+
}
|
|
934
|
+
|
|
906
935
|
export function toMarkdown(scan, meta={}){
|
|
907
936
|
const findings = normalizeFindings(scan);
|
|
908
937
|
const lines = ['# Agentic Security — Scan Report', ''];
|
|
@@ -935,6 +964,23 @@ export function toMarkdown(scan, meta={}){
|
|
|
935
964
|
lines.push(`| \`${f.file}:${f.line}\` | ${f.vuln} | ${f.cwe||'—'} | ${epss} | ${fix.replace(/\|/g,'\\|').slice(0,140)} |`);
|
|
936
965
|
}
|
|
937
966
|
}
|
|
967
|
+
// X-206: verification blocks come from the shared projection, never a second renderer.
|
|
968
|
+
const withVerification = bySev[sev].filter(f => verificationBlock(f));
|
|
969
|
+
if (withVerification.length) {
|
|
970
|
+
lines.push('');
|
|
971
|
+
lines.push('<details><summary>Verification</summary>');
|
|
972
|
+
lines.push('');
|
|
973
|
+
for (const f of withVerification) {
|
|
974
|
+
const block = verificationBlock(f).join('\n');
|
|
975
|
+
lines.push(`**\`${f.file}:${f.line}\`**: ${f.vuln}`);
|
|
976
|
+
const fence = '`'.repeat(_mdFenceLen(block));
|
|
977
|
+
lines.push(fence);
|
|
978
|
+
lines.push(block);
|
|
979
|
+
lines.push(fence);
|
|
980
|
+
lines.push('');
|
|
981
|
+
}
|
|
982
|
+
lines.push('</details>');
|
|
983
|
+
}
|
|
938
984
|
// FR-PROV-018: one provenance block per finding that has one, reusing
|
|
939
985
|
// explainProvenance's content — never a second, divergent renderer.
|
|
940
986
|
const withProvenance = bySev[sev].filter(f => f.findingProvenance);
|
|
@@ -1432,6 +1478,8 @@ export function toCLI(scan, { verbose=false, color=true, provenance=false }={}){
|
|
|
1432
1478
|
if (ex.how) lines.push(` ${c('how:', DIM)} ${ex.how}`);
|
|
1433
1479
|
if (ex.fix) lines.push(` ${c('fix:', DIM)} ${ex.fix}`);
|
|
1434
1480
|
if (ex.fixCode) for (const ln of ex.fixCode.split('\n').slice(0, 6)) lines.push(` ${c(ln, DIM)}`);
|
|
1481
|
+
const vblock = verificationBlock(f);
|
|
1482
|
+
if (vblock) for (const ln of vblock) lines.push(` ${c(ln, DIM)}`);
|
|
1435
1483
|
if (provenance) {
|
|
1436
1484
|
// `f` here is already normalized, so its findingProvenance has been
|
|
1437
1485
|
// through redactFindingProvenance — the email is gone before it can
|
|
@@ -1443,6 +1491,7 @@ export function toCLI(scan, { verbose=false, color=true, provenance=false }={}){
|
|
|
1443
1491
|
lines.push('');
|
|
1444
1492
|
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
1445
1493
|
for (const f of findings) counts[f.severity] = (counts[f.severity]||0) + 1;
|
|
1494
|
+
if (findings.some(f => f.verificationRecord)) lines.push(verificationCoverage(findings.map(f => f.verificationRecord).filter(Boolean)).lines[0]);
|
|
1446
1495
|
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
1496
|
return lines.join('\n');
|
|
1448
1497
|
}
|
package/src/sandbox/CLAUDE.md
CHANGED
|
@@ -347,6 +347,47 @@ 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
|
+
|
|
350
391
|
## Extending this module
|
|
351
392
|
|
|
352
393
|
- 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,114 @@
|
|
|
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
|
-
function _profile({ allowNetwork }) {
|
|
21
|
+
function _profile({ allowNetwork, denyRead = [] }) {
|
|
21
22
|
return [
|
|
22
23
|
'(version 1)',
|
|
23
24
|
'(deny default)',
|
|
24
25
|
'(allow process-exec process-fork)',
|
|
25
26
|
'(allow sysctl-read)',
|
|
26
27
|
'(allow file-read*)',
|
|
28
|
+
// Read denial (CORE-003). SBPL is last-match-wins, so these deny rules MUST
|
|
29
|
+
// come after the blanket read allow. Each path is a parameter, never
|
|
30
|
+
// spliced into the profile text, so a path cannot inject policy.
|
|
31
|
+
...denyRead.map((_, i) => `(deny file-read* (subpath (param "DENY${i}")))`),
|
|
27
32
|
'(allow file-write* (subpath (param "ROOT")))',
|
|
28
33
|
allowNetwork ? '(allow network*)' : '',
|
|
29
34
|
].filter(Boolean).join('\n');
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Build the exact spawn invocation for a userspace-confined command, without
|
|
39
|
+
* running it. Shared by the synchronous runner below and the supervised
|
|
40
|
+
* (process-tree-killing) runner, so both are confined by the SAME profile.
|
|
41
|
+
* Returns `{ error }` in the documented error shape instead of throwing.
|
|
42
|
+
*/
|
|
43
|
+
export function buildUserspaceInvocation(argv, {
|
|
33
44
|
root,
|
|
34
|
-
timeoutMs = 10000,
|
|
35
45
|
allowNetwork = false,
|
|
36
46
|
limits = {},
|
|
37
47
|
env = {},
|
|
38
|
-
|
|
48
|
+
denyReadPaths = [],
|
|
39
49
|
} = {}) {
|
|
40
|
-
|
|
41
|
-
// "falls back" is a classic route to unconfined execution.
|
|
42
|
-
if (!root) return errorResult('userspace', 'runUserspace requires a sandbox root');
|
|
50
|
+
if (!root) return { error: errorResult('userspace', 'runUserspace requires a sandbox root') };
|
|
43
51
|
|
|
44
52
|
const bin = resolveUserspaceBin();
|
|
45
|
-
if (!bin) return errorResult('userspace', 'no userspace confinement binary found on this host');
|
|
53
|
+
if (!bin) return { error: errorResult('userspace', 'no userspace confinement binary found on this host') };
|
|
46
54
|
|
|
47
55
|
let resolvedRoot;
|
|
48
56
|
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
57
|
resolvedRoot = fs.realpathSync(root);
|
|
52
58
|
} catch (e) {
|
|
53
|
-
return errorResult('userspace', `sandbox root is not usable: ${e.message}`);
|
|
59
|
+
return { error: errorResult('userspace', `sandbox root is not usable: ${e.message}`) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Resolve each denied path as far as it exists (a symlink would otherwise
|
|
63
|
+
// let the same file be reached by a path the profile does not name). A path
|
|
64
|
+
// that does not exist yet is denied by its literal text.
|
|
65
|
+
const denyRead = [];
|
|
66
|
+
for (const p of denyReadPaths || []) {
|
|
67
|
+
if (typeof p !== 'string' || !p) continue;
|
|
68
|
+
let real = path.resolve(p);
|
|
69
|
+
try { real = fs.realpathSync(real); } catch { /* not present: deny the literal path */ }
|
|
70
|
+
if (!denyRead.includes(real)) denyRead.push(real);
|
|
54
71
|
}
|
|
55
72
|
|
|
56
73
|
const effectiveLimits = {
|
|
57
74
|
...limits,
|
|
58
75
|
maxProcs: limits.maxProcs ?? ambientRelativeMaxProcs(),
|
|
59
76
|
};
|
|
60
|
-
|
|
61
77
|
let prelude, unsupported;
|
|
62
78
|
try {
|
|
63
79
|
({ prelude, unsupported } = buildLimitPrelude(effectiveLimits));
|
|
64
80
|
} catch (e) {
|
|
65
|
-
return errorResult('userspace', `invalid resource limit: ${e.message}`);
|
|
81
|
+
return { error: errorResult('userspace', `invalid resource limit: ${e.message}`) };
|
|
66
82
|
}
|
|
67
83
|
const inner = `${prelude}exec "$@"`;
|
|
68
84
|
|
|
69
|
-
|
|
85
|
+
return {
|
|
70
86
|
bin,
|
|
71
|
-
[
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
args: [
|
|
88
|
+
'-p', _profile({ allowNetwork, denyRead }),
|
|
89
|
+
'-D', `ROOT=${resolvedRoot}`,
|
|
90
|
+
...denyRead.flatMap((p, i) => ['-D', `DENY${i}=${p}`]),
|
|
91
|
+
'/bin/sh', '-c', inner, '_sbx', ...argv,
|
|
92
|
+
],
|
|
93
|
+
cwd: resolvedRoot,
|
|
94
|
+
env: buildConfinedEnv({ root: resolvedRoot, env }),
|
|
95
|
+
unsupported,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function runUserspace(argv, {
|
|
100
|
+
root,
|
|
101
|
+
timeoutMs = 10000,
|
|
102
|
+
allowNetwork = false,
|
|
103
|
+
limits = {},
|
|
104
|
+
env = {},
|
|
105
|
+
maxBuffer = 8 * 1024 * 1024,
|
|
106
|
+
denyReadPaths = [],
|
|
107
|
+
} = {}) {
|
|
108
|
+
// Documented shape, never a throw: a caller that wraps this in try/catch and
|
|
109
|
+
// "falls back" is a classic route to unconfined execution.
|
|
110
|
+
const inv = buildUserspaceInvocation(argv, { root, allowNetwork, limits, env, denyReadPaths });
|
|
111
|
+
if (inv.error) return inv.error;
|
|
112
|
+
|
|
113
|
+
const r = spawnSync(inv.bin, inv.args, {
|
|
114
|
+
encoding: 'utf8',
|
|
115
|
+
timeout: timeoutMs,
|
|
116
|
+
// Match the namespace backend: SIGKILL cannot be ignored, SIGTERM can.
|
|
117
|
+
// A payload that installs a SIGTERM handler would otherwise outlive its
|
|
118
|
+
// own budget while the caller is told it timed out.
|
|
119
|
+
killSignal: 'SIGKILL',
|
|
120
|
+
maxBuffer,
|
|
121
|
+
cwd: inv.cwd,
|
|
122
|
+
env: inv.env,
|
|
123
|
+
});
|
|
85
124
|
|
|
86
|
-
return buildResult({ backend: 'userspace', spawnResult: r, unsupported });
|
|
125
|
+
return buildResult({ backend: 'userspace', spawnResult: r, unsupported: inv.unsupported });
|
|
87
126
|
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Active probes for every control the boundary relies on (CORE-003.AC02).
|
|
2
|
+
//
|
|
3
|
+
// A control is "proved" only when a probe ATTACKED it through the real
|
|
4
|
+
// backend and the attack observably failed, AND a paired positive control
|
|
5
|
+
// showed the same probe succeeds when the control is off (so a probe that can
|
|
6
|
+
// never succeed cannot pass for a working control). An executable existing, a
|
|
7
|
+
// flag being passed or an earlier run succeeding proves nothing here.
|
|
8
|
+
//
|
|
9
|
+
// States:
|
|
10
|
+
// proved attack failed, positive control succeeded, both observed
|
|
11
|
+
// not-proved the probe ran and the control did not hold, or the probe could
|
|
12
|
+
// not establish its own positive control
|
|
13
|
+
// unsupported this backend does not implement the control at all
|
|
14
|
+
// unavailable no confinement backend works on this host
|
|
15
|
+
// unverified deliberately not probed; the reason says why (never a pass)
|
|
16
|
+
//
|
|
17
|
+
// The probes are generic, so on a Linux host they run against the namespace
|
|
18
|
+
// backend for real. Nothing in this file asserts a Linux outcome in advance.
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import net from 'node:net';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { detectBackend } from './capabilities.js';
|
|
25
|
+
import { runConfined } from './index.js';
|
|
26
|
+
import { runConfinedSupervised } from './supervise.js';
|
|
27
|
+
|
|
28
|
+
export const CONTROLS = Object.freeze([
|
|
29
|
+
'write-confinement', 'read-denial', 'env-scrub', 'network',
|
|
30
|
+
'tree-termination', 'file-size-limit', 'process-cap',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
34
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
|
|
35
|
+
const NODE = process.execPath;
|
|
36
|
+
|
|
37
|
+
function mk(prefix) { return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); }
|
|
38
|
+
function rm(p) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
39
|
+
const proved = (evidence) => ({ state: 'proved', evidence });
|
|
40
|
+
const notProved = (reason) => ({ state: 'not-proved', reason });
|
|
41
|
+
|
|
42
|
+
function probeWrite() {
|
|
43
|
+
const root = mk('agsec-probe-w-'); const outside = mk('agsec-probe-wo-');
|
|
44
|
+
try {
|
|
45
|
+
const good = runConfined(['/bin/sh', '-c', 'echo x > "$ROOT/in.txt"'], { root });
|
|
46
|
+
const inOk = fs.existsSync(path.join(root, 'in.txt'));
|
|
47
|
+
runConfined(['/bin/sh', '-c', `echo x > '${outside}/escape.txt'; true`], { root });
|
|
48
|
+
const escaped = fs.existsSync(path.join(outside, 'escape.txt'));
|
|
49
|
+
if (!inOk) return notProved(`positive control failed: in-root write did not happen (${good.status})`);
|
|
50
|
+
if (escaped) return notProved('an out-of-root write succeeded');
|
|
51
|
+
return proved('in-root write succeeded; out-of-root write created no file');
|
|
52
|
+
} finally { rm(root); rm(outside); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function probeReadDenial(backend) {
|
|
56
|
+
if (backend !== 'userspace') return { state: 'unsupported', reason: `read denial of host paths is not implemented on the ${backend} backend` };
|
|
57
|
+
const root = mk('agsec-probe-r-'); const secretDir = mk('agsec-probe-rs-');
|
|
58
|
+
try {
|
|
59
|
+
const secret = path.join(secretDir, 'sealed.txt');
|
|
60
|
+
const token = `SEALED-${crypto.randomBytes(8).toString('hex')}`;
|
|
61
|
+
fs.writeFileSync(secret, token);
|
|
62
|
+
const cmd = ['/bin/sh', '-c', `cat '${secret}' 2>&1; true`];
|
|
63
|
+
const open = runConfined(cmd, { root });
|
|
64
|
+
if (!open.stdout.includes(token)) return notProved('positive control failed: the probe could not read the canary even without denial');
|
|
65
|
+
const closed = runConfined(cmd, { root, denyReadPaths: [secretDir] });
|
|
66
|
+
if (closed.stdout.includes(token)) return notProved('a denied path was still readable');
|
|
67
|
+
return proved('canary readable without denial, unreadable with denyReadPaths');
|
|
68
|
+
} finally { rm(root); rm(secretDir); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function probeEnv() {
|
|
72
|
+
const root = mk('agsec-probe-e-');
|
|
73
|
+
const name = 'AGSEC_PROBE_PARENT_CANARY';
|
|
74
|
+
const prev = process.env[name];
|
|
75
|
+
process.env[name] = 'parent-env-canary-value';
|
|
76
|
+
try {
|
|
77
|
+
const r = runConfined(['/usr/bin/env'], { root, env: { AGSEC_PROBE_EXPLICIT: 'explicit-ok' } });
|
|
78
|
+
if (!r.stdout.includes('AGSEC_PROBE_EXPLICIT=explicit-ok')) return notProved(`positive control failed: explicit env not visible (${r.status})`);
|
|
79
|
+
if (r.stdout.includes(name) || r.stdout.includes('parent-env-canary-value')) return notProved('the parent environment leaked into the target');
|
|
80
|
+
return proved('explicit variable visible, parent canary variable absent');
|
|
81
|
+
} finally {
|
|
82
|
+
if (prev === undefined) delete process.env[name]; else process.env[name] = prev;
|
|
83
|
+
rm(root);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function probeNetwork() {
|
|
88
|
+
const root = mk('agsec-probe-n-');
|
|
89
|
+
let hits = 0;
|
|
90
|
+
const server = net.createServer((s) => { hits++; s.destroy(); });
|
|
91
|
+
try {
|
|
92
|
+
await new Promise((res, rej) => { server.once('error', rej); server.listen(0, '127.0.0.1', res); });
|
|
93
|
+
const port = server.address().port;
|
|
94
|
+
const script = `require('net').connect(${port},'127.0.0.1').on('error',()=>process.exit(0)).on('connect',()=>process.exit(0));setTimeout(()=>process.exit(0),1500)`;
|
|
95
|
+
runConfined([NODE, '-e', script], { root, timeoutMs: 6000, allowNetwork: true });
|
|
96
|
+
await sleep(200);
|
|
97
|
+
const open = hits;
|
|
98
|
+
if (open < 1) return notProved('positive control failed: loopback listener saw no connection with network allowed');
|
|
99
|
+
runConfined([NODE, '-e', script], { root, timeoutMs: 6000, allowNetwork: false });
|
|
100
|
+
await sleep(200);
|
|
101
|
+
if (hits > open) return notProved('a connection reached a loopback listener with network denied');
|
|
102
|
+
return proved('connection seen with network allowed, none with network denied');
|
|
103
|
+
} finally { server.close(); rm(root); }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function probeTree() {
|
|
107
|
+
const root = mk('agsec-probe-t-');
|
|
108
|
+
try {
|
|
109
|
+
// Three tree shapes: a plain background child, a grandchild, and one that
|
|
110
|
+
// ignores SIGTERM and so needs the SIGKILL escalation.
|
|
111
|
+
const script = [
|
|
112
|
+
'sleep 300 & echo $! > "$ROOT/p1"',
|
|
113
|
+
'(sleep 300 & echo $! > "$ROOT/p2"; wait) &',
|
|
114
|
+
`(trap '' TERM; sleep 300 & echo $! > "$ROOT/p3"; while :; do sleep 1; done) &`,
|
|
115
|
+
'wait',
|
|
116
|
+
].join('\n');
|
|
117
|
+
const r = await runConfinedSupervised(['/bin/sh', '-c', script], { root, timeoutMs: 1500, graceMs: 300 });
|
|
118
|
+
if (r.backend !== 'userspace' || !r.supervised) {
|
|
119
|
+
return { state: 'unsupported', reason: `supervised tree termination is not implemented on the ${r.backend} backend (${r.status})` };
|
|
120
|
+
}
|
|
121
|
+
const pids = ['p1', 'p2', 'p3'].map((f) => { try { return Number(fs.readFileSync(path.join(root, f), 'utf8')); } catch { return NaN; } });
|
|
122
|
+
if (pids.some((p) => !Number.isFinite(p))) return notProved('positive control failed: the probe tree did not start');
|
|
123
|
+
await sleep(100);
|
|
124
|
+
const live = pids.filter(alive);
|
|
125
|
+
for (const p of live) { try { process.kill(p, 'SIGKILL'); } catch { /* probe cleanup */ } }
|
|
126
|
+
if (live.length) return notProved(`processes survived the timeout: ${live.join(',')}`);
|
|
127
|
+
return proved('timeout ended a three-process tree including a SIGTERM-ignoring member');
|
|
128
|
+
} finally { rm(root); }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function probeFileSize() {
|
|
132
|
+
const root = mk('agsec-probe-f-');
|
|
133
|
+
try {
|
|
134
|
+
const cmd = ['/bin/sh', '-c', 'dd if=/dev/zero of="$ROOT/big" bs=1024 count=400 2>&1; true'];
|
|
135
|
+
runConfined(cmd, { root, limits: { maxFileSizeKb: 65536 } });
|
|
136
|
+
const full = fs.existsSync(path.join(root, 'big')) ? fs.statSync(path.join(root, 'big')).size : 0;
|
|
137
|
+
if (full < 400 * 1024) return notProved('positive control failed: the unrestricted write did not complete');
|
|
138
|
+
fs.rmSync(path.join(root, 'big'));
|
|
139
|
+
runConfined(cmd, { root, limits: { maxFileSizeKb: 16 } });
|
|
140
|
+
const capped = fs.existsSync(path.join(root, 'big')) ? fs.statSync(path.join(root, 'big')).size : 0;
|
|
141
|
+
if (capped >= 400 * 1024) return notProved('the file size limit did not stop a large write');
|
|
142
|
+
return proved(`400 KiB write completed unrestricted and stopped at ${capped} bytes under a 16 KiB limit`);
|
|
143
|
+
} finally { rm(root); }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const UNPROBED_PROCESS_CAP = {
|
|
147
|
+
state: 'unverified',
|
|
148
|
+
reason: 'a process-count cap is per-uid and system-wide on macOS (a soft brake, not a wall) and did not refuse on the hosted Linux runner in the last release; no enforcement is claimed on any backend',
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const _cache = new Map();
|
|
152
|
+
export function resetProbeCache() { _cache.clear(); }
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Probe every control on the active backend.
|
|
156
|
+
* @param {object} [o]
|
|
157
|
+
* @param {string} [o.force] backend override (same meaning as runConfined's)
|
|
158
|
+
* @param {object} [o.probes] per-control probe overrides (test seam). A probe
|
|
159
|
+
* may only make the report MORE restrictive in
|
|
160
|
+
* practice, since the boundary consults the report
|
|
161
|
+
* before any run; it cannot make a run unconfined.
|
|
162
|
+
*/
|
|
163
|
+
export async function probeControls({ force, probes = {} } = {}) {
|
|
164
|
+
const backend = detectBackend({ force });
|
|
165
|
+
const key = `${backend}:${Object.keys(probes).sort().join(',')}`;
|
|
166
|
+
if (!Object.keys(probes).length && _cache.has(backend)) return _cache.get(backend);
|
|
167
|
+
|
|
168
|
+
const controls = {};
|
|
169
|
+
if (backend === 'disabled') {
|
|
170
|
+
for (const c of CONTROLS) controls[c] = { state: 'unavailable', reason: 'no confinement backend works on this host' };
|
|
171
|
+
} else {
|
|
172
|
+
const run = async (name, fn) => {
|
|
173
|
+
if (probes[name]) { controls[name] = await probes[name](); return; }
|
|
174
|
+
try { controls[name] = await fn(); } catch (e) { controls[name] = notProved(`probe threw: ${e.message}`); }
|
|
175
|
+
};
|
|
176
|
+
await run('write-confinement', probeWrite);
|
|
177
|
+
await run('read-denial', () => probeReadDenial(backend));
|
|
178
|
+
await run('env-scrub', probeEnv);
|
|
179
|
+
await run('network', probeNetwork);
|
|
180
|
+
await run('tree-termination', probeTree);
|
|
181
|
+
await run('file-size-limit', probeFileSize);
|
|
182
|
+
controls['process-cap'] = probes['process-cap'] ? await probes['process-cap']() : UNPROBED_PROCESS_CAP;
|
|
183
|
+
}
|
|
184
|
+
const report = { platform: process.platform, backend, controls };
|
|
185
|
+
if (!Object.keys(probes).length) _cache.set(backend, report);
|
|
186
|
+
void key;
|
|
187
|
+
return report;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Controls from `required` that are not proved, with the reason for each. */
|
|
191
|
+
export function unmetControls(report, required) {
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const c of required) {
|
|
194
|
+
const v = report?.controls?.[c];
|
|
195
|
+
if (!v || v.state !== 'proved') out.push({ control: c, state: v?.state ?? 'not-probed', reason: v?.reason ?? 'no probe result' });
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Human-readable capability matrix. States what each control is on this host
|
|
202
|
+
* and carries the standing disclosure that Linux enforcement is only evidenced
|
|
203
|
+
* where the hosted `sandbox-linux` job actually ran.
|
|
204
|
+
*/
|
|
205
|
+
export function capabilityReport(report) {
|
|
206
|
+
return {
|
|
207
|
+
...report,
|
|
208
|
+
advertisedIsolationBackends: ['userspace (macOS family)', 'namespace (Linux family)'],
|
|
209
|
+
notes: [
|
|
210
|
+
'A state of proved was established by an active probe on THIS host in THIS process; it is not inherited from another platform.',
|
|
211
|
+
'Linux enforcement cannot be exercised on a macOS host. It is evidenced only where the sandbox-linux CI job ran this same probe set.',
|
|
212
|
+
'Process-count caps are not claimed enforced on any backend.',
|
|
213
|
+
],
|
|
214
|
+
};
|
|
215
|
+
}
|