@clear-capabilities/agentic-security-scanner 0.127.0 → 0.130.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 +161 -0
- package/bin/agentic-security.js +33 -0
- package/dist/11.index.js +353 -0
- package/dist/113.index.js +727 -0
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +217 -0
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +1 -1
- package/dist/435.index.js +19 -8
- package/dist/526.index.js +555 -0
- package/dist/637.index.js +1 -1
- package/dist/826.index.js +4 -1
- package/dist/830.index.js +1 -1
- package/dist/agentic-security.mjs +113 -163
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +23 -15
- package/src/dataflow/CLAUDE.md +4 -1
- package/src/dataflow/async-sequencing.js +8 -3
- package/src/dataflow/catalog.js +278 -11
- package/src/dataflow/cross-repo.js +1 -1
- package/src/dataflow/cross-service-taint.js +1 -1
- package/src/dataflow/engine.js +182 -61
- package/src/dataflow/ifds.js +10 -5
- package/src/dataflow/index.js +15 -3
- package/src/dataflow/points-to.js +8 -2
- package/src/dataflow/proof-gate.js +7 -0
- package/src/dataflow/sanitizer-gate.js +89 -0
- package/src/dataflow/tabulation.js +14 -3
- package/src/engine.js +181 -8
- package/src/integrations/index.js +1 -1
- package/src/integrations/tickets.js +9 -3
- package/src/ir/CLAUDE.md +49 -4
- package/src/ir/call-sites.js +66 -0
- package/src/ir/callgraph.js +174 -7
- package/src/ir/class-hierarchy.js +22 -2
- package/src/ir/index.js +138 -51
- package/src/ir/ir-stats.js +126 -0
- package/src/ir/parser-cpp.js +829 -0
- package/src/ir/parser-cs.js +4 -1
- package/src/ir/parser-go.js +4 -1
- package/src/ir/parser-js.js +5 -1
- package/src/ir/parser-kt.js +4 -1
- package/src/ir/parser-php.js +10 -3
- package/src/ir/parser-py-cst.js +62 -10
- package/src/ir/tree-sitter-loader.js +13 -1
- package/src/llm-validator/index.js +9 -2
- package/src/llm-validator/redact.js +157 -0
- package/src/mcp/tools.js +17 -6
- package/src/posture/CLAUDE.md +122 -0
- package/src/posture/accuracy-scorecard.js +317 -0
- package/src/posture/api-contract.js +1 -1
- package/src/posture/attestation.js +199 -0
- package/src/posture/auditor-walkthrough.js +12 -3
- package/src/posture/compliance-policy.js +1 -1
- package/src/posture/cross-lang-openapi.js +1 -1
- package/src/posture/custom-rules.js +1 -1
- package/src/posture/entrypoint-inventory.js +248 -0
- package/src/posture/execution-proof.js +52 -0
- package/src/posture/exploitability-probability.js +1 -1
- package/src/posture/falsification.js +165 -0
- package/src/posture/fix-honesty-gate.js +175 -0
- package/src/posture/fix-verify.js +71 -3
- package/src/posture/license-policy.js +1 -1
- package/src/posture/model-routing.js +126 -0
- package/src/posture/profile.js +1 -1
- package/src/posture/proof-tier.js +33 -0
- package/src/posture/relevance.js +379 -0
- package/src/posture/root-cause-sweep.js +262 -0
- package/src/posture/rule-overrides.js +1 -1
- package/src/posture/sca-policy.js +1 -1
- package/src/posture/scan-checkpoint.js +277 -0
- package/src/posture/suppressions.js +1 -1
- package/src/posture/test-runner.js +147 -0
- package/src/posture/verification-separation.js +131 -0
- package/src/pr-comment.js +3 -1
- package/src/report/index.js +11 -0
- package/src/runScan.js +3 -1
- package/src/sandbox/CLAUDE.md +218 -0
- package/src/sandbox/backend-disabled.js +14 -0
- package/src/sandbox/backend-namespace.js +83 -0
- package/src/sandbox/backend-userspace.js +100 -0
- package/src/sandbox/capabilities.js +53 -0
- package/src/sandbox/index.js +30 -0
- package/src/sandbox/limits.js +42 -0
- package/src/sandbox/result.js +104 -0
- package/src/sca/dep-confusion.js +1 -1
- package/src/util/untrusted.js +148 -0
- package/src/util/yaml.js +24 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// R5 (partial, roadmap) — the project's own test suite as a verification
|
|
2
|
+
// stage for `verifyFix()` (see `fix-verify.js`). Closes the gap where a
|
|
3
|
+
// "verified" fix only proved a finding's stableId stopped firing — a patch
|
|
4
|
+
// that deletes the feature entirely would satisfy that just as well as a
|
|
5
|
+
// real fix. Running the project's own tests is the cheapest available check
|
|
6
|
+
// that the application still works.
|
|
7
|
+
//
|
|
8
|
+
// Execution-safety note: this spawns the TARGET PROJECT's own test command
|
|
9
|
+
// in the target project's own directory. That is deliberately NOT routed
|
|
10
|
+
// through the R1 confinement sandbox (`../sandbox/`). That sandbox exists to
|
|
11
|
+
// contain untrusted proof-of-concept exploit code the scanner itself
|
|
12
|
+
// synthesizes — code nobody has vetted, being run for the first time. A
|
|
13
|
+
// project's pre-existing test suite is the opposite case: it is the
|
|
14
|
+
// project's own trusted source, already sitting on disk, and running it is
|
|
15
|
+
// exactly what a human developer does by hand before trusting a fix.
|
|
16
|
+
// Wrapping "npm test" / "pytest" / "go test" in the PoC sandbox's
|
|
17
|
+
// syscall/network/filesystem restrictions would break the large majority of
|
|
18
|
+
// real test suites (they bind local ports, spawn child processes, write temp
|
|
19
|
+
// fixtures, etc.) for no corresponding security benefit.
|
|
20
|
+
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import * as fs from 'node:fs';
|
|
23
|
+
import * as path from 'node:path';
|
|
24
|
+
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
26
|
+
const NPM_PLACEHOLDER = /Error: no test specified/i;
|
|
27
|
+
|
|
28
|
+
function _exists(scanRoot, rel) {
|
|
29
|
+
try { return fs.existsSync(path.join(scanRoot, rel)); } catch { return false; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function _isDir(scanRoot, rel) {
|
|
33
|
+
try { return fs.statSync(path.join(scanRoot, rel)).isDirectory(); } catch { return false; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function _binaryAvailable(cmd) {
|
|
37
|
+
try {
|
|
38
|
+
const r = spawnSync(cmd, ['--version'], { timeout: 5_000, stdio: 'ignore' });
|
|
39
|
+
return !(r.error && r.error.code === 'ENOENT');
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Detect the project's test command. Read-only — never spawns the actual
|
|
46
|
+
// test run, only (optionally) a cheap `--version` probe to confirm a tool
|
|
47
|
+
// like `pytest` is actually installed before committing to it. Returns
|
|
48
|
+
// `null` when nothing detectable is found — most scanned repos will hit
|
|
49
|
+
// this path, and that must not be treated as a failure by callers.
|
|
50
|
+
export function detectTestCommand(scanRoot) {
|
|
51
|
+
if (!scanRoot) return null;
|
|
52
|
+
|
|
53
|
+
// JS/TS — package.json with a real (non-placeholder) `scripts.test`.
|
|
54
|
+
let pkg = null;
|
|
55
|
+
try { pkg = JSON.parse(fs.readFileSync(path.join(scanRoot, 'package.json'), 'utf8')); } catch { pkg = null; }
|
|
56
|
+
const testScript = pkg && pkg.scripts && pkg.scripts.test;
|
|
57
|
+
if (testScript && !NPM_PLACEHOLDER.test(String(testScript))) {
|
|
58
|
+
if (_exists(scanRoot, 'pnpm-lock.yaml')) return { cmd: 'pnpm', args: ['test'], kind: 'pnpm' };
|
|
59
|
+
if (_exists(scanRoot, 'yarn.lock')) return { cmd: 'yarn', args: ['test'], kind: 'yarn' };
|
|
60
|
+
if (_exists(scanRoot, 'bun.lockb') || _exists(scanRoot, 'bun.lock')) return { cmd: 'bun', args: ['test'], kind: 'bun' };
|
|
61
|
+
return { cmd: 'npm', args: ['test', '--silent'], kind: 'npm' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Python — pytest.ini / pyproject.toml / tox.ini / a tests/ dir, and the
|
|
65
|
+
// `pytest` binary actually available. If pytest isn't installed we do NOT
|
|
66
|
+
// report a python test command — falling through lets a later language
|
|
67
|
+
// marker (e.g. go.mod in a polyglot repo) still be detected.
|
|
68
|
+
const pyMarker = _exists(scanRoot, 'pytest.ini') || _exists(scanRoot, 'pyproject.toml') ||
|
|
69
|
+
_exists(scanRoot, 'tox.ini') || _isDir(scanRoot, 'tests');
|
|
70
|
+
if (pyMarker && _binaryAvailable('pytest')) {
|
|
71
|
+
return { cmd: 'pytest', args: ['-q'], kind: 'pytest' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Go
|
|
75
|
+
if (_exists(scanRoot, 'go.mod')) {
|
|
76
|
+
return { cmd: 'go', args: ['test', './...'], kind: 'go' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Run the detected test command with a walltime budget. Always returns a
|
|
83
|
+
// result object — never throws. Distinguishes four outcomes:
|
|
84
|
+
// - no detectable/runnable command -> status: 'skipped' (does NOT fail)
|
|
85
|
+
// - ran and exited 0 -> status: 'passed'
|
|
86
|
+
// - ran and exited non-zero -> status: 'failed'
|
|
87
|
+
// - ran past the timeout budget -> status: 'failed', timedOut: true
|
|
88
|
+
export function runProjectTests(scanRoot, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
89
|
+
const startedAt = Date.now();
|
|
90
|
+
const command = detectTestCommand(scanRoot);
|
|
91
|
+
if (!command) {
|
|
92
|
+
return {
|
|
93
|
+
status: 'skipped', passed: null, skipped: true,
|
|
94
|
+
reason: 'no-test-command-detected', exitCode: null, timedOut: false,
|
|
95
|
+
durationMs: Date.now() - startedAt,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let r;
|
|
100
|
+
try {
|
|
101
|
+
r = spawnSync(command.cmd, command.args, {
|
|
102
|
+
cwd: scanRoot,
|
|
103
|
+
encoding: 'utf8',
|
|
104
|
+
timeout: timeoutMs,
|
|
105
|
+
env: { ...process.env, CI: '1' },
|
|
106
|
+
});
|
|
107
|
+
} catch (e) {
|
|
108
|
+
// The spawn call itself threw (rare — e.g. cwd vanished). Treat as
|
|
109
|
+
// "could not run", not "ran and failed".
|
|
110
|
+
return {
|
|
111
|
+
status: 'skipped', passed: null, skipped: true,
|
|
112
|
+
reason: `spawn-error: ${e.message}`, exitCode: null, timedOut: false,
|
|
113
|
+
durationMs: Date.now() - startedAt,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const durationMs = Date.now() - startedAt;
|
|
117
|
+
|
|
118
|
+
if (r.error && r.error.code === 'ENOENT') {
|
|
119
|
+
// The detected tool isn't actually installed on this machine. Not a
|
|
120
|
+
// test failure — the suite never ran.
|
|
121
|
+
return {
|
|
122
|
+
status: 'skipped', passed: null, skipped: true,
|
|
123
|
+
reason: `${command.kind}-not-installed`, exitCode: null, timedOut: false, durationMs,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (r.status === null) {
|
|
128
|
+
// spawnSync sets status:null both on timeout-kill and on being killed by
|
|
129
|
+
// another signal; either way the run did not complete, which is a
|
|
130
|
+
// verification failure, never a skip — we asked for a result and the
|
|
131
|
+
// process was terminated before producing one.
|
|
132
|
+
return {
|
|
133
|
+
status: 'failed', passed: false, skipped: false,
|
|
134
|
+
reason: 'timed-out', exitCode: null, timedOut: true, durationMs,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
status: r.status === 0 ? 'passed' : 'failed',
|
|
140
|
+
passed: r.status === 0,
|
|
141
|
+
skipped: false,
|
|
142
|
+
reason: r.status === 0 ? null : 'test-failures',
|
|
143
|
+
exitCode: r.status,
|
|
144
|
+
timedOut: false,
|
|
145
|
+
durationMs,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// R7 — adversarial verification with ENFORCED SEPARATION.
|
|
2
|
+
//
|
|
3
|
+
// The falsification pass (`falsification.js`) already tries to DISPROVE a
|
|
4
|
+
// finding. What it could not previously do is *prove* that whoever checked the
|
|
5
|
+
// finding was not whoever produced it. That guarantee is what this module adds,
|
|
6
|
+
// plus a recorded multi-perspective verdict for contested findings.
|
|
7
|
+
//
|
|
8
|
+
// The property that matters: a verifier is structurally unable to rubber-stamp
|
|
9
|
+
// its own finding.
|
|
10
|
+
// - `recordProducer` stamps who produced the finding, WRITE-ONCE. A later
|
|
11
|
+
// party cannot re-stamp itself as the producer to manufacture separation.
|
|
12
|
+
// - `assertSeparation` refuses when the verifier id equals the recorded
|
|
13
|
+
// producer id, and FAILS CLOSED when no producer was recorded at all
|
|
14
|
+
// (separation that cannot be established is not separation).
|
|
15
|
+
// - `recordVerdict` runs that check itself, so there is no code path that
|
|
16
|
+
// records a verdict without it. One verifier gets one vote per lens: a
|
|
17
|
+
// re-vote replaces the previous one rather than stuffing the ballot.
|
|
18
|
+
//
|
|
19
|
+
// RECALL-PRESERVING, same precedent as `falsification.js` and
|
|
20
|
+
// `dataflow/proof-gate.js`: nothing here ever removes a finding and nothing
|
|
21
|
+
// here ever touches `severity`. A `refuted` consensus is a triage signal, not
|
|
22
|
+
// a deletion — absence of proof is not proof of absence.
|
|
23
|
+
//
|
|
24
|
+
// NO THROWING (posture/CLAUDE.md convention): every entry point returns a
|
|
25
|
+
// refusal object `{ ok:false, refused:true, reason }` instead of throwing, so
|
|
26
|
+
// an annotator can call it inside the engine's pipeline without a guard.
|
|
27
|
+
|
|
28
|
+
export const VERIFICATION_VERDICTS = ['upheld', 'refuted', 'undecided'];
|
|
29
|
+
|
|
30
|
+
// Verifier ids are namespaced so they can never collide with a producer id.
|
|
31
|
+
export const VERIFIER_FALSIFICATION = 'verifier:falsification';
|
|
32
|
+
export const VERIFIER_LLM_REVIEW = 'verifier:llm-review';
|
|
33
|
+
|
|
34
|
+
function _refuse(reason) { return { ok: false, refused: true, reason }; }
|
|
35
|
+
|
|
36
|
+
/** Namespaced id for whoever produced a finding — derived from its detector. */
|
|
37
|
+
export function producerIdOf(finding) {
|
|
38
|
+
const p = finding && finding.parser ? String(finding.parser) : 'unknown';
|
|
39
|
+
return `detector:${p}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Stamp who produced this finding. Write-once: a second call with a different
|
|
44
|
+
* id is REFUSED (this is what stops a verifier reassigning provenance to
|
|
45
|
+
* itself). A repeat call with the same id is a no-op success.
|
|
46
|
+
*/
|
|
47
|
+
export function recordProducer(finding, producerId) {
|
|
48
|
+
if (!finding || typeof finding !== 'object') return _refuse('no finding');
|
|
49
|
+
const id = producerId ? String(producerId) : '';
|
|
50
|
+
if (!id) return _refuse('no producer id');
|
|
51
|
+
if (!finding.verification || typeof finding.verification !== 'object') {
|
|
52
|
+
finding.verification = { producer: id, verdicts: [] };
|
|
53
|
+
return { ok: true, producer: id };
|
|
54
|
+
}
|
|
55
|
+
if (!finding.verification.producer) {
|
|
56
|
+
finding.verification.producer = id;
|
|
57
|
+
if (!Array.isArray(finding.verification.verdicts)) finding.verification.verdicts = [];
|
|
58
|
+
return { ok: true, producer: id };
|
|
59
|
+
}
|
|
60
|
+
if (finding.verification.producer === id) return { ok: true, producer: id };
|
|
61
|
+
return _refuse(`producer already recorded as "${finding.verification.producer}" — write-once`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The separation check. `{ ok:true, producer }` when the verifier is a party
|
|
66
|
+
* other than the producer; a refusal otherwise. Fails closed when no producer
|
|
67
|
+
* has been recorded.
|
|
68
|
+
*/
|
|
69
|
+
export function assertSeparation(finding, verifierId) {
|
|
70
|
+
if (!finding || typeof finding !== 'object') return _refuse('no finding');
|
|
71
|
+
const vid = verifierId ? String(verifierId) : '';
|
|
72
|
+
if (!vid) return _refuse('no verifier id');
|
|
73
|
+
const producer = finding.verification && finding.verification.producer;
|
|
74
|
+
if (!producer) {
|
|
75
|
+
return _refuse('no producer recorded — separation cannot be established');
|
|
76
|
+
}
|
|
77
|
+
if (producer === vid) {
|
|
78
|
+
return _refuse(`verifier "${vid}" is the producer of this finding — separation violated`);
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, producer };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Record one verifier's verdict from one perspective (`lens`, e.g.
|
|
85
|
+
* 'reachability' | 'control-flow' | 'data-shape'). Refuses — recording
|
|
86
|
+
* nothing — when separation fails or the verdict is not one of
|
|
87
|
+
* VERIFICATION_VERDICTS. Never touches severity, never removes anything.
|
|
88
|
+
*/
|
|
89
|
+
export function recordVerdict(finding, { verifierId, lens, verdict, reason } = {}) {
|
|
90
|
+
if (!finding || typeof finding !== 'object') return _refuse('no finding');
|
|
91
|
+
if (!lens) return _refuse('no lens');
|
|
92
|
+
if (!VERIFICATION_VERDICTS.includes(verdict)) {
|
|
93
|
+
return _refuse(`unknown verdict "${verdict}" — expected one of ${VERIFICATION_VERDICTS.join('|')}`);
|
|
94
|
+
}
|
|
95
|
+
const sep = assertSeparation(finding, verifierId);
|
|
96
|
+
if (!sep.ok) return sep;
|
|
97
|
+
|
|
98
|
+
const entry = {
|
|
99
|
+
verifierId: String(verifierId),
|
|
100
|
+
lens: String(lens),
|
|
101
|
+
verdict,
|
|
102
|
+
...(reason ? { reason: String(reason) } : {}),
|
|
103
|
+
};
|
|
104
|
+
const list = finding.verification.verdicts;
|
|
105
|
+
const i = list.findIndex(v => v.verifierId === entry.verifierId && v.lens === entry.lens);
|
|
106
|
+
if (i >= 0) list[i] = entry; else list.push(entry);
|
|
107
|
+
return { ok: true, recorded: entry };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Majority across every recorded verdict. Ties — including "no verdicts at
|
|
112
|
+
* all" — are 'undecided'. `lenses` is the sorted set of perspectives that
|
|
113
|
+
* actually voted, so a caller can see whether a verdict is one-eyed or a panel.
|
|
114
|
+
*/
|
|
115
|
+
export function consensusOf(finding) {
|
|
116
|
+
const list = (finding && finding.verification && Array.isArray(finding.verification.verdicts))
|
|
117
|
+
? finding.verification.verdicts : [];
|
|
118
|
+
let upheld = 0, refuted = 0, undecided = 0;
|
|
119
|
+
const lenses = new Set();
|
|
120
|
+
for (const v of list) {
|
|
121
|
+
if (!v) continue;
|
|
122
|
+
if (v.lens) lenses.add(String(v.lens));
|
|
123
|
+
if (v.verdict === 'upheld') upheld++;
|
|
124
|
+
else if (v.verdict === 'refuted') refuted++;
|
|
125
|
+
else if (v.verdict === 'undecided') undecided++;
|
|
126
|
+
}
|
|
127
|
+
let verdict = 'undecided';
|
|
128
|
+
if (upheld > refuted) verdict = 'upheld';
|
|
129
|
+
else if (refuted > upheld) verdict = 'refuted';
|
|
130
|
+
return { verdict, upheld, refuted, undecided, lenses: [...lenses].sort() };
|
|
131
|
+
}
|
package/src/pr-comment.js
CHANGED
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
// route through an LLM for richer prose when AGENTIC_SECURITY_LLM_ENDPOINT
|
|
26
26
|
// is configured.
|
|
27
27
|
|
|
28
|
+
import { escapeMarkdown } from './util/untrusted.js';
|
|
29
|
+
|
|
28
30
|
const SEVERITY_GLYPH = {
|
|
29
31
|
critical: '🟥',
|
|
30
32
|
high: '🟧',
|
|
@@ -136,7 +138,7 @@ export function renderPrComment(delta, { repoName, prNumber, prTitle } = {}) {
|
|
|
136
138
|
const sev = SEVERITY_GLYPH[f.severity] || '⬜';
|
|
137
139
|
const route = _route(f);
|
|
138
140
|
const where = route ? `\`${route}\` (\`${f.file}:${f.line}\`)` : `\`${f.file}:${f.line}\``;
|
|
139
|
-
lines.push(`${sev} **${meta?.name || f.vuln}** — ${where}`);
|
|
141
|
+
lines.push(`${sev} **${meta?.name || escapeMarkdown(f.vuln)}** — ${where}`);
|
|
140
142
|
if (meta) lines.push(` > ${meta.why}`);
|
|
141
143
|
if (f.remediation) {
|
|
142
144
|
const onelineFix = String(f.remediation).split('\n')[0].slice(0, 240);
|
package/src/report/index.js
CHANGED
|
@@ -180,6 +180,11 @@ export function normalizeFindings(scan){
|
|
|
180
180
|
paramKeyConfidence: f.poc.paramKeyConfidence || null,
|
|
181
181
|
paramKeyInferred: typeof f.poc.paramKeyInferred === 'boolean' ? f.poc.paramKeyInferred : null,
|
|
182
182
|
} : null,
|
|
183
|
+
// R2: execution-proof tier — copied through only when the annotator
|
|
184
|
+
// (posture/proof-tier.js) actually attached it. Never synthesised here;
|
|
185
|
+
// a finding with no proof backing simply omits these fields.
|
|
186
|
+
...(f.proofTier !== undefined ? { proofTier: f.proofTier } : {}),
|
|
187
|
+
...(f.proofEvidence !== undefined ? { proofEvidence: f.proofEvidence } : {}),
|
|
183
188
|
// Phase-1 next-gen P1.3 (FR-UX-1, FR-UX-2): calibrated probability +
|
|
184
189
|
// 95% Wilson CI + sample size. Null when N < MIN_SAMPLES_FOR_CALIBRATION
|
|
185
190
|
// for this family; `calibration_reason` explains why.
|
|
@@ -401,6 +406,12 @@ export function toJSON(scan, meta={}, opts={}){
|
|
|
401
406
|
// threw and were skipped. The findings still ship; downstream consumers
|
|
402
407
|
// see the gap.
|
|
403
408
|
annotatorErrors: Array.isArray(scan.annotatorErrors) ? scan.annotatorErrors : [],
|
|
409
|
+
// R4 — run attestation: a stable, order-independent digest over this
|
|
410
|
+
// finding set bound to the engine/ruleset/bundle that produced it.
|
|
411
|
+
// Attached by the CLI (posture/attestation.js); null when not computed.
|
|
412
|
+
// It carries its own `proves` / `doesNotProve` statement — do not quote
|
|
413
|
+
// the digest as cross-machine reproducibility, which it is not.
|
|
414
|
+
attestation: scan.attestation || null,
|
|
404
415
|
_scanMeta: scan._scanMeta || null,
|
|
405
416
|
};
|
|
406
417
|
if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
|
package/src/runScan.js
CHANGED
|
@@ -122,7 +122,9 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
// R8: `resume` is opt-in. Left undefined here, runFullScan falls back to the
|
|
126
|
+
// AGENTIC_SECURITY_RESUME=1 env var, which is off by default.
|
|
127
|
+
const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume }, opts.onProgress || (()=>{}));
|
|
126
128
|
// Premortem 2R4.2: stamp ruleset version + source on the scan result, and
|
|
127
129
|
// notify if the operator pinned a different version than what's installed.
|
|
128
130
|
try { stampScan(root, scan); } catch {}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# src/sandbox/
|
|
2
|
+
|
|
3
|
+
Confined execution facility for running untrusted target code and candidate
|
|
4
|
+
exploits (R1 of `docs/ROADMAP.md`). This is a hard prerequisite for anything
|
|
5
|
+
that executes code the scanner did not write — no other module in this
|
|
6
|
+
repository runs target code, confined or otherwise.
|
|
7
|
+
|
|
8
|
+
## Entry point
|
|
9
|
+
|
|
10
|
+
Everything goes through `index.js`:
|
|
11
|
+
|
|
12
|
+
- `sandboxAvailable() -> boolean` — true iff a real confinement primitive was
|
|
13
|
+
detected on this host.
|
|
14
|
+
- `runConfined(argv, opts) -> { status, denied, stdout, stderr, exitCode, timedOut, backend }`
|
|
15
|
+
— dispatches to whichever backend `detectBackend()` selected. `opts.force`
|
|
16
|
+
overrides detection (used by tests, and by any caller that wants to force
|
|
17
|
+
the disabled path deliberately).
|
|
18
|
+
|
|
19
|
+
`status` is one of `'ok' | 'blocked' | 'nonzero' | 'timeout' | 'disabled' |
|
|
20
|
+
'error'`. All three backends return the identical shape, so callers never
|
|
21
|
+
branch on which backend ran. **`runConfined` never throws** — a missing
|
|
22
|
+
`root`, an unresolvable root, a missing confinement binary, or an invalid
|
|
23
|
+
resource limit all return `status: 'error'` in the normal shape. (A caller
|
|
24
|
+
that wraps it in `try`/`catch` and "falls back" is a classic route to
|
|
25
|
+
unconfined execution, so there is nothing to catch.)
|
|
26
|
+
|
|
27
|
+
## `blocked` vs `nonzero` vs `ok` — and the limit of what is observable
|
|
28
|
+
|
|
29
|
+
An earlier version derived `status` purely from the exit code, which conflated
|
|
30
|
+
two unrelated outcomes: a program that ran fine and exited 3 was reported
|
|
31
|
+
`'blocked'`, while a program whose out-of-root write was **denied** but which
|
|
32
|
+
exited 0 was reported `'ok'` — a clean run, as far as the caller could tell.
|
|
33
|
+
Both are now separated:
|
|
34
|
+
|
|
35
|
+
| Field | Meaning |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `denied: true` | A confinement violation was **observed** in the confined process's error output. |
|
|
38
|
+
| `status: 'blocked'` | `denied` was true — something was refused. |
|
|
39
|
+
| `status: 'nonzero'` | The command exited non-zero with **no** denial observed. Ordinary program failure, not a confinement event. |
|
|
40
|
+
| `status: 'ok'` | Exited 0 with no denial observed. |
|
|
41
|
+
|
|
42
|
+
**What `denied: false` does not mean.** The signal is read from the confined
|
|
43
|
+
process's own stderr — these OS primitives give the parent no structured
|
|
44
|
+
violation channel. A program that writes outside the root and swallows its own
|
|
45
|
+
error message produces no signal at all, so `denied: false` means "no denial
|
|
46
|
+
was observed", **not** "no denial occurred". `status: 'ok'` is proof that the
|
|
47
|
+
command exited 0 and said nothing about a refusal; it is **not** proof that
|
|
48
|
+
the sandbox refused nothing. Downstream consumers (e.g. an R2 execution
|
|
49
|
+
verification tier) must not read `'ok'` as "ran unimpeded". The reliable
|
|
50
|
+
negative evidence remains the one the escape tests use: check for the side
|
|
51
|
+
effect (the out-of-root file does not exist), not the status.
|
|
52
|
+
|
|
53
|
+
## Backend selection (`capabilities.js`)
|
|
54
|
+
|
|
55
|
+
`detectBackend({ force })` probes for one confinement primitive, cached after
|
|
56
|
+
the first call (`resetCapabilityCache()` clears it, used between tests):
|
|
57
|
+
|
|
58
|
+
| Platform | Primitive checked | Backend selected |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| macOS family | userspace confinement binary present and executable | `'userspace'` |
|
|
61
|
+
| Linux family | kernel-namespace tool present and executable | `'namespace'` |
|
|
62
|
+
| neither found | — | `'disabled'` |
|
|
63
|
+
|
|
64
|
+
Each primitive is probed across a **candidate list** of plausible install
|
|
65
|
+
paths (`CONFINE_BINS_USERSPACE` / `CONFINE_BINS_NAMESPACE`), not a single
|
|
66
|
+
hardcoded path. A miss still fails closed to `'disabled'`, which is safe — but
|
|
67
|
+
a single path would be a false negative on any distribution that installs the
|
|
68
|
+
binary elsewhere, silently costing that host its sandbox. The backends run the
|
|
69
|
+
resolved path, not the canonical one.
|
|
70
|
+
|
|
71
|
+
## Fail-closed rule
|
|
72
|
+
|
|
73
|
+
If no primitive is found, `detectBackend` returns `'disabled'` and
|
|
74
|
+
`runConfined` dispatches to `backend-disabled.js`, which **refuses to execute
|
|
75
|
+
the command at all** — it returns `status: 'disabled'` without ever spawning
|
|
76
|
+
a process. There is no code path in this module that runs target code
|
|
77
|
+
unconfined. An unavailable sandbox disables the execution feature; it never
|
|
78
|
+
silently degrades to running the command directly. This is proven by an
|
|
79
|
+
executing test (`sandbox.test.js`): the disabled backend is invoked with a
|
|
80
|
+
command that would create a marker file, and the test asserts the file does
|
|
81
|
+
not exist afterward.
|
|
82
|
+
|
|
83
|
+
## What is verified on which platform
|
|
84
|
+
|
|
85
|
+
This module was developed and its tests run on a macOS host. Guarantees below
|
|
86
|
+
are stated per platform — do not extrapolate one platform's result to the
|
|
87
|
+
other.
|
|
88
|
+
|
|
89
|
+
**Userspace backend (macOS family) — verified by execution on this platform:**
|
|
90
|
+
- A write outside the sandbox root is blocked; the target file is never
|
|
91
|
+
created.
|
|
92
|
+
- Outbound network connections are blocked.
|
|
93
|
+
- A wall-clock overrun stops the **direct child** (`status: 'timeout'`,
|
|
94
|
+
`timedOut: true`) — but see "Timeout does not kill the process tree" below.
|
|
95
|
+
This is not full termination and must not be described as such.
|
|
96
|
+
- Benign in-root work (writes inside the root, ordinary commands) still
|
|
97
|
+
succeeds — the gate holds in both directions, not just the blocking one.
|
|
98
|
+
- Fork-storm containment is **weak, not strong, on this platform**. The
|
|
99
|
+
process-count limit (`ulimit -u` / `RLIMIT_NPROC`) is a per-uid, **system-wide**
|
|
100
|
+
cap here, not a per-process-tree cap — it counts every process the user
|
|
101
|
+
owns on the whole machine, not just the sandboxed subtree. Ambient process
|
|
102
|
+
count for a normal user on this host is on the order of several hundred, so
|
|
103
|
+
any usable cap has to sit at "ambient + margin" or it starves the user's own
|
|
104
|
+
unrelated processes before the sandboxed command even starts. That means a
|
|
105
|
+
fork storm inside the sandbox can still spawn a meaningful number of
|
|
106
|
+
processes — bounded to ambient-plus-margin, not to some small absolute
|
|
107
|
+
number — before the cap bites. Treat this as a soft brake, not a hard wall.
|
|
108
|
+
- Address-space capping (`ulimit -v`) is **not enforceable** on this platform.
|
|
109
|
+
`limits.js` (`buildLimitPrelude`) detects this and reports the limit in its
|
|
110
|
+
`unsupported` array instead of emitting a `ulimit -v` line that would
|
|
111
|
+
silently do nothing — an unenforced limit must never look like an enforced
|
|
112
|
+
one.
|
|
113
|
+
|
|
114
|
+
**Kernel-namespace backend (Linux family) — implemented, NOT verified on this
|
|
115
|
+
platform.** The required namespace tool is absent on the macOS development
|
|
116
|
+
host, so `backend-namespace.js`'s escape tests skip with a recorded reason
|
|
117
|
+
rather than being asserted against. Nothing in this guide should be read as a
|
|
118
|
+
claim that the namespace backend's isolation has been demonstrated by
|
|
119
|
+
execution anywhere. It must be verified on a Linux host — with the same
|
|
120
|
+
both-direction escape-attempt tests used for the userspace backend — before
|
|
121
|
+
anything downstream (e.g. an R2 execution-verification tier) relies on it.
|
|
122
|
+
|
|
123
|
+
**And it confines less than "unverified" suggests. Writes are NOT confined on
|
|
124
|
+
this backend — that is false by inspection, not merely undemonstrated.** The
|
|
125
|
+
backend enters new mount/PID/IPC/UTS namespaces and, by default, an empty
|
|
126
|
+
network namespace. The empty network namespace is the *only* confinement it
|
|
127
|
+
implements: it has no route anywhere, which denies egress. For the
|
|
128
|
+
filesystem there is **no remount, no bind mount and no `pivot_root`** — only a
|
|
129
|
+
`cd` into the sandbox root. `cd` sets the working directory; it does not
|
|
130
|
+
restrict where a process may write. A confined command writing to an absolute
|
|
131
|
+
path outside the root (a home directory, a system config path) will
|
|
132
|
+
**succeed**, subject only to ordinary filesystem permissions. The new mount
|
|
133
|
+
namespace isolates mount-table *changes* made by the confined process; it does
|
|
134
|
+
not make the host filesystem read-only.
|
|
135
|
+
|
|
136
|
+
On a Linux host `detectBackend()` selects this backend automatically, so a
|
|
137
|
+
caller there gets network isolation and resource limits and **no write
|
|
138
|
+
confinement at all**. Do not run anything on that path that must not touch the
|
|
139
|
+
host filesystem. Closing the gap means implementing a read-only remount (or
|
|
140
|
+
equivalent) *and* verifying it by execution on a Linux host with both-direction
|
|
141
|
+
escape tests — the guide must not claim write confinement here before both
|
|
142
|
+
have happened.
|
|
143
|
+
|
|
144
|
+
## Timeout does not kill the process tree
|
|
145
|
+
|
|
146
|
+
`timeoutMs` is enforced with `spawnSync`'s timeout, which signals **only the
|
|
147
|
+
process this module spawned**. Verified by execution on the macOS family: with
|
|
148
|
+
`timeoutMs: 1200`, a command that backgrounded a 4-second child returned
|
|
149
|
+
`status: 'timeout'` and the grandchild survived, completing its work *after*
|
|
150
|
+
the result was returned. So `'timeout'` means "we stopped waiting and killed
|
|
151
|
+
the process we spawned", not "the process tree was terminated". Survivors stay
|
|
152
|
+
inside the policy profile — their writes and egress remain confined — but they
|
|
153
|
+
are still running and still consuming resources. A caller that needs a hard
|
|
154
|
+
tree kill must implement it.
|
|
155
|
+
|
|
156
|
+
The namespace backend is structurally better here: it runs the confined
|
|
157
|
+
command under `--pid --fork`, so the direct child is pid 1 of a new PID
|
|
158
|
+
namespace and killing it should take the namespace's processes with it. That
|
|
159
|
+
is a reasoned expectation from the flags, **not** an executed result — it
|
|
160
|
+
needs the same Linux-host verification as everything else on that backend.
|
|
161
|
+
|
|
162
|
+
## Known limitation, deliberately accepted: reads are not confined
|
|
163
|
+
|
|
164
|
+
The userspace policy allows `(allow file-read*)` globally — that backend
|
|
165
|
+
confines **writes**, network egress, and resource use, but **not reads**. (The
|
|
166
|
+
kernel-namespace backend confines *less* than that: per the section above, it
|
|
167
|
+
implements network isolation only and does **not** confine writes at all.)
|
|
168
|
+
A confined command can read any file on the host the OS-level
|
|
169
|
+
permissions allow, including outside the sandbox root. Exfiltration of
|
|
170
|
+
readable host files (writing what was read to network or to a location the
|
|
171
|
+
attacker later reads through some other channel) is **out of scope for this
|
|
172
|
+
module**. This is a deliberate R1 scope cut, not an oversight: tightening
|
|
173
|
+
reads requires a threat model for what a confined process may legitimately
|
|
174
|
+
need to read, which belongs with the execution-verification work that
|
|
175
|
+
consumes this sandbox, not with the sandbox primitive itself.
|
|
176
|
+
|
|
177
|
+
### The parent environment is NOT one of the things a confined process may read
|
|
178
|
+
|
|
179
|
+
Secrets carried in the parent process's environment (API tokens, cloud keys,
|
|
180
|
+
registry auth) are a *distinct* exposure from unconfined file reads — the
|
|
181
|
+
sandbox would be handing them over rather than merely failing to hide them —
|
|
182
|
+
so they are not covered by the scope cut above. Every real backend therefore
|
|
183
|
+
runs the command with a **minimal constructed environment**
|
|
184
|
+
(`buildConfinedEnv` in `result.js`): `PATH`, `ROOT`, `HOME`, `TMPDIR`, `LANG`,
|
|
185
|
+
with `HOME`/`TMPDIR` pointed at the sandbox root. `process.env` is not
|
|
186
|
+
forwarded. A caller that genuinely needs a variable inside passes it
|
|
187
|
+
explicitly as `opts.env`, which is merged on top of the base — an opt-in, one
|
|
188
|
+
variable at a time, not a blanket export.
|
|
189
|
+
|
|
190
|
+
## Resource limits (`limits.js`)
|
|
191
|
+
|
|
192
|
+
`buildLimitPrelude({ maxProcs, maxFileSizeKb, maxAddressSpaceKb })` returns
|
|
193
|
+
`{ prelude, unsupported }`. `prelude` is a shell fragment of `ulimit` calls to
|
|
194
|
+
prefix before the confined command; `unsupported` lists any requested limit
|
|
195
|
+
that the current platform cannot enforce, so a caller can log or surface that
|
|
196
|
+
degradation rather than assume the limit applied silently.
|
|
197
|
+
|
|
198
|
+
Limit values are interpolated into a shell fragment, so they are **coerced
|
|
199
|
+
with `Number()` and rejected unless finite and non-negative** (`RangeError`,
|
|
200
|
+
which the backends turn into `status: 'error'`). Before that, a
|
|
201
|
+
config-supplied string such as `'999; echo INJECTED'` was emitted verbatim and
|
|
202
|
+
its payload ran — not an escape (the prelude runs inside the confinement) but
|
|
203
|
+
a way for a config-derived value to silently *disable* the limits it was
|
|
204
|
+
supposed to set.
|
|
205
|
+
|
|
206
|
+
## Extending this module
|
|
207
|
+
|
|
208
|
+
- Both real backends (`backend-userspace.js`, `backend-namespace.js`) must
|
|
209
|
+
keep returning the exact same result shape as each other and as
|
|
210
|
+
`backend-disabled.js` — callers dispatch on `status`/`backend`, not on
|
|
211
|
+
which module ran.
|
|
212
|
+
- Any new backend must add its own both-direction escape test
|
|
213
|
+
(`sandbox-escape.test.js`) before being wired into `index.js`: a `GOOD` case
|
|
214
|
+
showing legitimate in-root work still succeeds, and one `BAD` case per
|
|
215
|
+
escape vector the backend claims to block.
|
|
216
|
+
- Do not add a code path that runs a command when `detectBackend()` returns
|
|
217
|
+
`'disabled'`. If a future backend needs a new capability check, add it to
|
|
218
|
+
`detectBackend`, not around it.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Fail-closed backend. Selected when no confinement primitive is available.
|
|
2
|
+
// It must NEVER execute the command — an unavailable sandbox disables
|
|
3
|
+
// execution features, it does not bypass them.
|
|
4
|
+
export function runDisabled(_argv, _opts) {
|
|
5
|
+
return {
|
|
6
|
+
status: 'disabled',
|
|
7
|
+
denied: false,
|
|
8
|
+
stdout: '',
|
|
9
|
+
stderr: 'agentic-security: refusing to execute — no confinement primitive available on this host.',
|
|
10
|
+
exitCode: null,
|
|
11
|
+
timedOut: false,
|
|
12
|
+
backend: 'disabled',
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Kernel-namespace confinement backend (Linux family).
|
|
2
|
+
//
|
|
3
|
+
// STATUS: implemented, not verified on this platform — the required
|
|
4
|
+
// kernel-namespace tool is not present here, so its escape tests skip with a
|
|
5
|
+
// recorded reason. Verify on a Linux host before relying on it for R2.
|
|
6
|
+
//
|
|
7
|
+
// WHAT THIS BACKEND ACTUALLY CONFINES — do not overstate it. It enters new
|
|
8
|
+
// mount/PID/IPC/UTS namespaces and, unless `allowNetwork`, an empty network
|
|
9
|
+
// namespace. The empty network namespace has no route anywhere, and that is
|
|
10
|
+
// the ONE confinement this backend implements: network egress.
|
|
11
|
+
//
|
|
12
|
+
// It does NOT confine writes. There is no remount, no bind mount and no
|
|
13
|
+
// pivot_root here — only a `cd` into the sandbox root. A `cd` sets the working
|
|
14
|
+
// directory; it does not restrict where a process may write. A confined
|
|
15
|
+
// command that writes to an absolute path outside the root (a home directory,
|
|
16
|
+
// a system config path) will SUCCEED, subject only to ordinary filesystem
|
|
17
|
+
// permissions. The new mount namespace isolates mount-table CHANGES made by
|
|
18
|
+
// the confined process from the host; it does not make the host filesystem
|
|
19
|
+
// read-only. Treat filesystem confinement as ABSENT on this backend until a
|
|
20
|
+
// read-only remount is implemented AND verified by execution on a Linux host.
|
|
21
|
+
//
|
|
22
|
+
// TIMEOUT SCOPE. The wall-clock timeout is `spawnSync`'s, which signals only
|
|
23
|
+
// the direct child. On this backend the direct child is the namespace tool
|
|
24
|
+
// running as pid 1 of a new PID namespace (`--pid --fork`), so killing it is
|
|
25
|
+
// expected to take the whole namespace's processes with it — better than the
|
|
26
|
+
// userspace backend, where a backgrounded grandchild demonstrably survives.
|
|
27
|
+
// "Expected", not verified: like everything else here it needs a Linux host.
|
|
28
|
+
import { spawnSync } from 'node:child_process';
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import { resolveNamespaceBin } from './capabilities.js';
|
|
31
|
+
import { buildLimitPrelude } from './limits.js';
|
|
32
|
+
import { buildResult, errorResult, buildConfinedEnv } from './result.js';
|
|
33
|
+
|
|
34
|
+
export function runNamespace(argv, {
|
|
35
|
+
root,
|
|
36
|
+
timeoutMs = 10000,
|
|
37
|
+
allowNetwork = false,
|
|
38
|
+
limits = {},
|
|
39
|
+
env = {},
|
|
40
|
+
maxBuffer = 8 * 1024 * 1024,
|
|
41
|
+
} = {}) {
|
|
42
|
+
// Documented shape, never a throw — see the same note in backend-userspace.
|
|
43
|
+
if (!root) return errorResult('namespace', 'runNamespace requires a sandbox root');
|
|
44
|
+
|
|
45
|
+
const bin = resolveNamespaceBin();
|
|
46
|
+
if (!bin) return errorResult('namespace', 'no kernel-namespace binary found on this host');
|
|
47
|
+
|
|
48
|
+
let resolvedRoot;
|
|
49
|
+
try {
|
|
50
|
+
// Resolve symlinks so the path the kernel actually sees matches what we
|
|
51
|
+
// hand to the child.
|
|
52
|
+
resolvedRoot = fs.realpathSync(root);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return errorResult('namespace', `sandbox root is not usable: ${e.message}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let prelude, unsupported;
|
|
58
|
+
try {
|
|
59
|
+
({ prelude, unsupported } = buildLimitPrelude(limits));
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return errorResult('namespace', `invalid resource limit: ${e.message}`);
|
|
62
|
+
}
|
|
63
|
+
// `cd` only sets the working directory — it is NOT write confinement. See
|
|
64
|
+
// the header note.
|
|
65
|
+
const inner = `${prelude}cd "$ROOT" && exec "$@"`;
|
|
66
|
+
|
|
67
|
+
const nsArgs = ['--mount', '--pid', '--ipc', '--uts', '--fork'];
|
|
68
|
+
if (!allowNetwork) nsArgs.push('--net');
|
|
69
|
+
|
|
70
|
+
const r = spawnSync(
|
|
71
|
+
bin,
|
|
72
|
+
[...nsArgs, '/bin/sh', '-c', inner, '_sbx', ...argv],
|
|
73
|
+
{
|
|
74
|
+
encoding: 'utf8',
|
|
75
|
+
timeout: timeoutMs,
|
|
76
|
+
maxBuffer,
|
|
77
|
+
cwd: resolvedRoot,
|
|
78
|
+
env: buildConfinedEnv({ root: resolvedRoot, env }),
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return buildResult({ backend: 'namespace', spawnResult: r, unsupported });
|
|
83
|
+
}
|