@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,100 @@
|
|
|
1
|
+
// Userspace confinement backend (macOS family). Applies a deny-by-default
|
|
2
|
+
// policy profile: reads allowed, writes confined to the sandbox root, no
|
|
3
|
+
// network egress unless explicitly opted in.
|
|
4
|
+
//
|
|
5
|
+
// TIMEOUT SCOPE — read before trusting `status:'timeout'`. The wall-clock
|
|
6
|
+
// timeout is `spawnSync`'s, which signals only the DIRECT child. Verified by
|
|
7
|
+
// execution on this platform: with `timeoutMs: 1200`, a command that
|
|
8
|
+
// backgrounded a 4-second child returned `status:'timeout'` while the
|
|
9
|
+
// grandchild survived the timeout and completed its work afterwards. So
|
|
10
|
+
// 'timeout' means "we stopped waiting and killed the process we spawned", NOT
|
|
11
|
+
// "the process tree was terminated". Anything left running is still inside the
|
|
12
|
+
// policy profile (its writes and network stay confined), but it is still
|
|
13
|
+
// running. Callers that need a hard tree kill must supply it themselves.
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import { resolveUserspaceBin } from './capabilities.js';
|
|
17
|
+
import { buildLimitPrelude } from './limits.js';
|
|
18
|
+
import { buildResult, errorResult, buildConfinedEnv } from './result.js';
|
|
19
|
+
|
|
20
|
+
// `ulimit -u` (RLIMIT_NPROC) is a per-uid, system-wide cap on this platform,
|
|
21
|
+
// not a per-process-tree cap (verified by execution in Task 2). A fixed
|
|
22
|
+
// default like the 64 in buildLimitPrelude() breaks ordinary, non-adversarial
|
|
23
|
+
// runs on any host whose user already has more than ~64 ambient processes —
|
|
24
|
+
// which is common. So unless the caller passes an explicit maxProcs, we
|
|
25
|
+
// compute one relative to the ambient count for this uid, the same
|
|
26
|
+
// workaround Task 2 used in its own test, to keep default behavior usable
|
|
27
|
+
// without pretending a low fixed cap is real containment.
|
|
28
|
+
function _ambientProcCount() {
|
|
29
|
+
try {
|
|
30
|
+
const out = spawnSync('/bin/sh', ['-c', 'ps -U "$(id -un)" -o pid= | wc -l'], { encoding: 'utf8' });
|
|
31
|
+
return Number(String(out.stdout || '').trim()) || 200;
|
|
32
|
+
} catch {
|
|
33
|
+
return 200;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function _profile({ allowNetwork }) {
|
|
38
|
+
return [
|
|
39
|
+
'(version 1)',
|
|
40
|
+
'(deny default)',
|
|
41
|
+
'(allow process-exec process-fork)',
|
|
42
|
+
'(allow sysctl-read)',
|
|
43
|
+
'(allow file-read*)',
|
|
44
|
+
'(allow file-write* (subpath (param "ROOT")))',
|
|
45
|
+
allowNetwork ? '(allow network*)' : '',
|
|
46
|
+
].filter(Boolean).join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function runUserspace(argv, {
|
|
50
|
+
root,
|
|
51
|
+
timeoutMs = 10000,
|
|
52
|
+
allowNetwork = false,
|
|
53
|
+
limits = {},
|
|
54
|
+
env = {},
|
|
55
|
+
maxBuffer = 8 * 1024 * 1024,
|
|
56
|
+
} = {}) {
|
|
57
|
+
// Documented shape, never a throw: a caller that wraps this in try/catch and
|
|
58
|
+
// "falls back" is a classic route to unconfined execution.
|
|
59
|
+
if (!root) return errorResult('userspace', 'runUserspace requires a sandbox root');
|
|
60
|
+
|
|
61
|
+
const bin = resolveUserspaceBin();
|
|
62
|
+
if (!bin) return errorResult('userspace', 'no userspace confinement binary found on this host');
|
|
63
|
+
|
|
64
|
+
let resolvedRoot;
|
|
65
|
+
try {
|
|
66
|
+
// Resolve symlinks (e.g. macOS /var -> /private/var) so the profile's
|
|
67
|
+
// subpath param matches the path the kernel actually sees.
|
|
68
|
+
resolvedRoot = fs.realpathSync(root);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return errorResult('userspace', `sandbox root is not usable: ${e.message}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const effectiveLimits = {
|
|
74
|
+
...limits,
|
|
75
|
+
maxProcs: limits.maxProcs ?? (_ambientProcCount() + 64),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
let prelude, unsupported;
|
|
79
|
+
try {
|
|
80
|
+
({ prelude, unsupported } = buildLimitPrelude(effectiveLimits));
|
|
81
|
+
} catch (e) {
|
|
82
|
+
return errorResult('userspace', `invalid resource limit: ${e.message}`);
|
|
83
|
+
}
|
|
84
|
+
const inner = `${prelude}exec "$@"`;
|
|
85
|
+
|
|
86
|
+
const r = spawnSync(
|
|
87
|
+
bin,
|
|
88
|
+
['-p', _profile({ allowNetwork }), '-D', `ROOT=${resolvedRoot}`,
|
|
89
|
+
'/bin/sh', '-c', inner, '_sbx', ...argv],
|
|
90
|
+
{
|
|
91
|
+
encoding: 'utf8',
|
|
92
|
+
timeout: timeoutMs,
|
|
93
|
+
maxBuffer,
|
|
94
|
+
cwd: resolvedRoot,
|
|
95
|
+
env: buildConfinedEnv({ root: resolvedRoot, env }),
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return buildResult({ backend: 'userspace', spawnResult: r, unsupported });
|
|
100
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Detects which OS confinement primitive is available. Fail-closed: when none
|
|
2
|
+
// is found we report 'disabled', which REFUSES execution rather than running
|
|
3
|
+
// target code unconfined.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
// Referenced by path, never by product name (see Global Constraints).
|
|
7
|
+
//
|
|
8
|
+
// Each family lists every plausible install location, probed in order. A
|
|
9
|
+
// single hardcoded path is safe (a miss fails closed to 'disabled') but it is
|
|
10
|
+
// a FALSE NEGATIVE: a host that does have the primitive somewhere else loses
|
|
11
|
+
// the sandbox silently. Probing the candidate set removes that failure mode.
|
|
12
|
+
export const CONFINE_BINS_USERSPACE = Object.freeze([
|
|
13
|
+
'/usr/bin/sandbox-exec',
|
|
14
|
+
'/usr/local/bin/sandbox-exec',
|
|
15
|
+
]);
|
|
16
|
+
export const CONFINE_BINS_NAMESPACE = Object.freeze([
|
|
17
|
+
'/usr/bin/unshare',
|
|
18
|
+
'/bin/unshare',
|
|
19
|
+
'/usr/local/bin/unshare',
|
|
20
|
+
'/sbin/unshare',
|
|
21
|
+
'/usr/sbin/unshare',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
// Back-compat single-path exports: the first (canonical) candidate.
|
|
25
|
+
export const CONFINE_BIN_USERSPACE = CONFINE_BINS_USERSPACE[0];
|
|
26
|
+
export const CONFINE_BIN_NAMESPACE = CONFINE_BINS_NAMESPACE[0];
|
|
27
|
+
|
|
28
|
+
let _cached = null;
|
|
29
|
+
|
|
30
|
+
export function resetCapabilityCache() { _cached = null; }
|
|
31
|
+
|
|
32
|
+
/** First executable candidate, or null when none of them exists. */
|
|
33
|
+
export function resolveConfineBin(candidates) {
|
|
34
|
+
for (const p of candidates) if (_isExecutable(p)) return p;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resolveUserspaceBin() { return resolveConfineBin(CONFINE_BINS_USERSPACE); }
|
|
39
|
+
export function resolveNamespaceBin() { return resolveConfineBin(CONFINE_BINS_NAMESPACE); }
|
|
40
|
+
|
|
41
|
+
export function detectBackend({ force } = {}) {
|
|
42
|
+
if (force) return force;
|
|
43
|
+
if (_cached) return _cached;
|
|
44
|
+
let b = 'disabled';
|
|
45
|
+
if (process.platform === 'darwin' && resolveUserspaceBin()) b = 'userspace';
|
|
46
|
+
else if (process.platform === 'linux' && resolveNamespaceBin()) b = 'namespace';
|
|
47
|
+
_cached = b;
|
|
48
|
+
return b;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _isExecutable(p) {
|
|
52
|
+
try { fs.accessSync(p, fs.constants.X_OK); return true; } catch { return false; }
|
|
53
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Single entry point for confined execution.
|
|
2
|
+
//
|
|
3
|
+
// Fail-closed by construction: when no confinement primitive is available the
|
|
4
|
+
// disabled backend is selected, which REFUSES to execute. There is deliberately
|
|
5
|
+
// no code path that runs target code unconfined.
|
|
6
|
+
//
|
|
7
|
+
// Result shape (identical for every backend):
|
|
8
|
+
// { status, denied, stdout, stderr, exitCode, timedOut, backend }
|
|
9
|
+
// status: 'ok' | 'blocked' | 'nonzero' | 'timeout' | 'disabled' | 'error'.
|
|
10
|
+
// See result.js for what 'blocked' vs 'nonzero' mean and, importantly, what
|
|
11
|
+
// `denied:false` does NOT prove. runConfined never throws — a bad root or an
|
|
12
|
+
// invalid limit returns status 'error', because a caller that catches and
|
|
13
|
+
// falls back is a route to unconfined execution.
|
|
14
|
+
import { detectBackend } from './capabilities.js';
|
|
15
|
+
import { runDisabled } from './backend-disabled.js';
|
|
16
|
+
import { runUserspace } from './backend-userspace.js';
|
|
17
|
+
import { runNamespace } from './backend-namespace.js';
|
|
18
|
+
|
|
19
|
+
export { detectBackend, resetCapabilityCache } from './capabilities.js';
|
|
20
|
+
|
|
21
|
+
export function sandboxAvailable() {
|
|
22
|
+
return detectBackend() !== 'disabled';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function runConfined(argv, opts = {}) {
|
|
26
|
+
const backend = detectBackend({ force: opts.force });
|
|
27
|
+
if (backend === 'userspace') return runUserspace(argv, opts);
|
|
28
|
+
if (backend === 'namespace') return runNamespace(argv, opts);
|
|
29
|
+
return runDisabled(argv, opts);
|
|
30
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Resource caps applied as a shell prelude, shared by every real backend.
|
|
2
|
+
//
|
|
3
|
+
// Address-space capping (`ulimit -v`) is NOT enforced on the macOS family —
|
|
4
|
+
// verified by execution. We therefore DECLARE it unsupported rather than
|
|
5
|
+
// emitting a limit that silently does nothing, which would be a false
|
|
6
|
+
// assurance of containment.
|
|
7
|
+
export function buildLimitPrelude({
|
|
8
|
+
maxProcs = 64,
|
|
9
|
+
maxFileSizeKb = 65536,
|
|
10
|
+
maxAddressSpaceKb = null,
|
|
11
|
+
} = {}) {
|
|
12
|
+
const parts = [];
|
|
13
|
+
const unsupported = [];
|
|
14
|
+
|
|
15
|
+
if (maxProcs != null) parts.push(`ulimit -u ${_num('maxProcs', maxProcs)}`);
|
|
16
|
+
if (maxFileSizeKb != null) parts.push(`ulimit -f ${_num('maxFileSizeKb', maxFileSizeKb)}`);
|
|
17
|
+
|
|
18
|
+
if (maxAddressSpaceKb != null) {
|
|
19
|
+
if (process.platform === 'linux') parts.push(`ulimit -v ${_num('maxAddressSpaceKb', maxAddressSpaceKb)}`);
|
|
20
|
+
else unsupported.push('maxAddressSpaceKb');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const prelude = parts.length ? parts.join('; ') + '; ' : '';
|
|
24
|
+
return { prelude, unsupported };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Limit values are interpolated into a shell fragment, so a non-numeric value
|
|
29
|
+
* is shell text. Verified by execution: `maxProcs: '999; echo INJECTED'`
|
|
30
|
+
* emitted `ulimit -u 999; echo INJECTED` and the payload ran. That is not a
|
|
31
|
+
* sandbox escape (the prelude runs INSIDE the confinement) but it lets a
|
|
32
|
+
* config-derived value silently DISABLE the very limits it was meant to set —
|
|
33
|
+
* e.g. `'0 2>/dev/null; true'` swallows the failure. Coerce and reject
|
|
34
|
+
* anything that is not a finite, non-negative number.
|
|
35
|
+
*/
|
|
36
|
+
function _num(name, v) {
|
|
37
|
+
const n = Number(v);
|
|
38
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
39
|
+
throw new RangeError(`${name} must be a finite, non-negative number (got ${JSON.stringify(v)})`);
|
|
40
|
+
}
|
|
41
|
+
return Math.floor(n);
|
|
42
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Shared result construction for every real backend.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS (the misread it prevents): status used to be derived purely
|
|
4
|
+
// from the exit code — `exitCode !== 0` was reported as `'blocked'`. That
|
|
5
|
+
// conflates two entirely different outcomes:
|
|
6
|
+
//
|
|
7
|
+
// 1. A program that ran fine and chose to exit non-zero (a failing test, a
|
|
8
|
+
// grep with no match) was labelled 'blocked' — a false confinement claim.
|
|
9
|
+
// 2. A program whose out-of-root write was DENIED but which still exited 0
|
|
10
|
+
// was labelled 'ok' — the caller saw a clean run and could not tell that
|
|
11
|
+
// the sandbox had refused something. Verified by execution: the denied
|
|
12
|
+
// write returns exit 0 when the command swallows the failure.
|
|
13
|
+
//
|
|
14
|
+
// So the two signals are now separated:
|
|
15
|
+
//
|
|
16
|
+
// - `denied` — a confinement violation was OBSERVED in the child's error
|
|
17
|
+
// output. Best effort, see the honesty note below.
|
|
18
|
+
// - `status` — 'blocked' when a denial was observed, 'nonzero' when the
|
|
19
|
+
// command merely exited non-zero with no denial signal, 'ok'
|
|
20
|
+
// only when it exited 0 with no denial signal.
|
|
21
|
+
//
|
|
22
|
+
// HONESTY NOTE — what `denied:false` does and does not mean. The denial signal
|
|
23
|
+
// is read from the confined process's own stderr (the OS primitives here do
|
|
24
|
+
// not hand the parent a structured violation channel). A program that writes
|
|
25
|
+
// out of root and swallows its own error message produces NO signal, so
|
|
26
|
+
// `denied:false` means "no denial was observed", NOT "no denial occurred".
|
|
27
|
+
// Never treat `status:'ok'` as proof that nothing was refused. It is proof
|
|
28
|
+
// only that the command exited 0 and said nothing about a refusal.
|
|
29
|
+
|
|
30
|
+
const DENIAL_PATTERNS = [
|
|
31
|
+
/operation not permitted/i,
|
|
32
|
+
/permission denied/i,
|
|
33
|
+
/read-only file system/i,
|
|
34
|
+
/deny file-write/i,
|
|
35
|
+
/deny network/i,
|
|
36
|
+
/network is unreachable/i,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** True iff the confined process's error output shows an observed denial. */
|
|
40
|
+
export function detectDenial(stderr) {
|
|
41
|
+
const s = String(stderr || '');
|
|
42
|
+
return DENIAL_PATTERNS.some((re) => re.test(s));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the single result shape every backend returns. `status` is one of
|
|
47
|
+
* 'ok' | 'blocked' | 'nonzero' | 'timeout' | 'disabled' | 'error'.
|
|
48
|
+
*/
|
|
49
|
+
export function buildResult({ backend, spawnResult, unsupported = [] }) {
|
|
50
|
+
const r = spawnResult;
|
|
51
|
+
const rawStderr = r.stderr ?? '';
|
|
52
|
+
const timedOut = r.error?.code === 'ETIMEDOUT';
|
|
53
|
+
const denied = detectDenial(rawStderr);
|
|
54
|
+
|
|
55
|
+
let status;
|
|
56
|
+
if (timedOut) status = 'timeout';
|
|
57
|
+
else if (r.error) status = 'error';
|
|
58
|
+
else if (denied) status = 'blocked';
|
|
59
|
+
else if (r.status !== 0) status = 'nonzero';
|
|
60
|
+
else status = 'ok';
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
status,
|
|
64
|
+
denied,
|
|
65
|
+
stdout: r.stdout ?? '',
|
|
66
|
+
stderr: rawStderr + (unsupported.length ? `\n[sandbox] limits not enforceable here: ${unsupported.join(', ')}` : ''),
|
|
67
|
+
exitCode: r.status ?? null,
|
|
68
|
+
timedOut,
|
|
69
|
+
backend,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Documented-shape error result: runConfined never throws at its callers. */
|
|
74
|
+
export function errorResult(backend, message) {
|
|
75
|
+
return {
|
|
76
|
+
status: 'error',
|
|
77
|
+
denied: false,
|
|
78
|
+
stdout: '',
|
|
79
|
+
stderr: `agentic-security: ${message}`,
|
|
80
|
+
exitCode: null,
|
|
81
|
+
timedOut: false,
|
|
82
|
+
backend,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Minimal environment handed to untrusted code. The parent environment is NOT
|
|
88
|
+
* forwarded: it routinely carries credentials (tokens, cloud keys, registry
|
|
89
|
+
* auth) and the confined program can read and exfiltrate them. This is a
|
|
90
|
+
* distinct exposure from the accepted "reads are not confined" scope cut —
|
|
91
|
+
* that one is about files on disk, this one is about secrets the parent hands
|
|
92
|
+
* over for free. Callers that need extra variables pass them explicitly via
|
|
93
|
+
* `opts.env`, which is merged on top of this base.
|
|
94
|
+
*/
|
|
95
|
+
export function buildConfinedEnv({ root, env = {} } = {}) {
|
|
96
|
+
return {
|
|
97
|
+
PATH: '/usr/bin:/bin:/usr/sbin:/sbin',
|
|
98
|
+
ROOT: root,
|
|
99
|
+
HOME: root,
|
|
100
|
+
TMPDIR: root,
|
|
101
|
+
LANG: 'C',
|
|
102
|
+
...env,
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/sca/dep-confusion.js
CHANGED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Untrusted-content hardening primitives (addition #4: meta-security —
|
|
2
|
+
// self-hardening the agent surface).
|
|
3
|
+
//
|
|
4
|
+
// Attacker-authored code and finding text reach several LLM prompts, several
|
|
5
|
+
// rendered outputs (issue / PR / ticket bodies), and audit writers. This module
|
|
6
|
+
// is the single, tested place that neutralizes that content before it crosses a
|
|
7
|
+
// trust boundary. See docs/AGENT_THREAT_MODEL.md for the path→CWE map.
|
|
8
|
+
//
|
|
9
|
+
// Design notes:
|
|
10
|
+
// - Pure + dependency-light (node:crypto, node:fs only). No network, no state.
|
|
11
|
+
// - Fail-closed: unknown/adversarial input degrades to the safe value ('' or
|
|
12
|
+
// `false`), never throws.
|
|
13
|
+
// - Deterministic: fenceUntrusted derives its nonce from the content hash so
|
|
14
|
+
// the wrapping is reproducible and testable (no Date.now / random source).
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
|
|
19
|
+
// ─── escapeMarkdown ──────────────────────────────────────────────────────────
|
|
20
|
+
// Neutralize markdown/HTML control characters so attacker-controlled finding
|
|
21
|
+
// text cannot inject markup, links, or code spans when interpolated into an
|
|
22
|
+
// issue / PR / ticket body. HTML-dangerous chars (& < >) are entity-encoded so
|
|
23
|
+
// no raw tag can render in any markdown flavour; markdown-structural chars
|
|
24
|
+
// (backtick [ ] ! and backslash) are backslash-escaped.
|
|
25
|
+
//
|
|
26
|
+
// Non-strings collapse to '' (fail-closed — a null vuln never becomes "null").
|
|
27
|
+
//
|
|
28
|
+
// Order is load-bearing: escape `&` before we emit `&`/`<`/`>`, and
|
|
29
|
+
// escape literal `\` before we introduce our own backslashes, so nothing is
|
|
30
|
+
// double-consumed.
|
|
31
|
+
export function escapeMarkdown(s) {
|
|
32
|
+
if (typeof s !== 'string') return '';
|
|
33
|
+
return s
|
|
34
|
+
.replace(/&/g, '&')
|
|
35
|
+
.replace(/</g, '<')
|
|
36
|
+
.replace(/>/g, '>')
|
|
37
|
+
.replace(/\\/g, '\\\\')
|
|
38
|
+
.replace(/`/g, '\\`')
|
|
39
|
+
.replace(/\[/g, '\\[')
|
|
40
|
+
.replace(/\]/g, '\\]')
|
|
41
|
+
.replace(/!/g, '\\!');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── fenceUntrusted ──────────────────────────────────────────────────────────
|
|
45
|
+
// Wrap untrusted text in a clearly-delimited block whose delimiter carries a
|
|
46
|
+
// per-call nonce, so an injected close-delimiter inside the text cannot
|
|
47
|
+
// terminate the fence early (the classic prompt-injection "break out of the
|
|
48
|
+
// data block" move). Intended for the LLM-prompt paths (triage / dedup / fix)
|
|
49
|
+
// where the model must treat the wrapped span as inert data.
|
|
50
|
+
//
|
|
51
|
+
// The nonce is derived deterministically from a sha256 of the content (first 8
|
|
52
|
+
// hex). That makes it (a) reproducible/testable and (b) unguessable by the
|
|
53
|
+
// author of the content — an attacker cannot pre-compute the resulting nonce to
|
|
54
|
+
// forge a matching close-delimiter, because the nonce depends on the very bytes
|
|
55
|
+
// they would have to write.
|
|
56
|
+
//
|
|
57
|
+
// Returns { text, nonce }.
|
|
58
|
+
export function fenceUntrusted(s, label = 'untrusted') {
|
|
59
|
+
const content = typeof s === 'string' ? s : '';
|
|
60
|
+
const lbl = String(label || 'untrusted').replace(/[^A-Za-z0-9_-]/g, '');
|
|
61
|
+
const nonce = createHash('sha256').update(content, 'utf8').digest('hex').slice(0, 8);
|
|
62
|
+
const open = `<<BEGIN ${lbl} ${nonce}>>`;
|
|
63
|
+
const close = `<<END ${lbl} ${nonce}>>`;
|
|
64
|
+
return { text: `${open}\n${content}\n${close}`, nonce };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── isAllowedFetchHost ──────────────────────────────────────────────────────
|
|
68
|
+
// Guard for any outbound fetch whose URL can be influenced by untrusted finding
|
|
69
|
+
// data (e.g. a metadata/advisory URL lifted from a dependency manifest). Blocks
|
|
70
|
+
// SSRF against link-local / loopback / RFC1918 targets AND enforces an explicit
|
|
71
|
+
// allowlist — a host must be BOTH non-internal AND on the allowlist. Empty
|
|
72
|
+
// allowlist ⇒ nothing passes (fail-closed). Malformed URL ⇒ false.
|
|
73
|
+
export function isAllowedFetchHost(url, allowlist = []) {
|
|
74
|
+
let host;
|
|
75
|
+
try {
|
|
76
|
+
host = new URL(String(url)).hostname.toLowerCase();
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (!host) return false;
|
|
81
|
+
// Strip IPv6 brackets: "[::1]" → "::1".
|
|
82
|
+
const h = host.replace(/^\[/, '').replace(/\]$/, '');
|
|
83
|
+
|
|
84
|
+
// Block internal / link-local / loopback destinations up front — these must
|
|
85
|
+
// never be reachable even if an operator mistakenly allowlists one.
|
|
86
|
+
if (h === 'localhost' || h.endsWith('.localhost')) return false;
|
|
87
|
+
if (h === '::1' || h === '0.0.0.0') return false;
|
|
88
|
+
if (h === '169.254.169.254' || h.startsWith('169.254.')) return false; // link-local + cloud metadata
|
|
89
|
+
if (h.startsWith('127.')) return false; // loopback /8
|
|
90
|
+
if (h.startsWith('10.')) return false; // RFC1918 /8
|
|
91
|
+
if (h.startsWith('192.168.')) return false; // RFC1918 /16
|
|
92
|
+
const m172 = h.match(/^172\.(\d{1,3})\./); // RFC1918 172.16-31/12
|
|
93
|
+
if (m172) {
|
|
94
|
+
const oct = Number(m172[1]);
|
|
95
|
+
if (oct >= 16 && oct <= 31) return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const allow = Array.isArray(allowlist)
|
|
99
|
+
? allowlist.map((a) => String(a).toLowerCase())
|
|
100
|
+
: [];
|
|
101
|
+
if (allow.length === 0) return false; // fail-closed: no allowlist ⇒ deny all
|
|
102
|
+
return allow.includes(h);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── redactSecrets ───────────────────────────────────────────────────────────
|
|
106
|
+
// Mask token-shaped substrings before finding-adjacent text is written to an
|
|
107
|
+
// audit log or handed to an LLM. The provider/scheme prefix is preserved so a
|
|
108
|
+
// human triager can still tell WHAT kind of credential leaked without seeing
|
|
109
|
+
// its value. Non-strings collapse to ''.
|
|
110
|
+
const _REDACTED = '***REDACTED***';
|
|
111
|
+
export function redactSecrets(s) {
|
|
112
|
+
if (typeof s !== 'string') return '';
|
|
113
|
+
return s
|
|
114
|
+
// URL basic-auth: scheme://user:password@ → keep user, mask password.
|
|
115
|
+
.replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:)[^\s@/]+@/gi, `$1${_REDACTED}@`)
|
|
116
|
+
// Authorization: Bearer <token>
|
|
117
|
+
.replace(/\b(Authorization\s*:\s*Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, `$1${_REDACTED}`)
|
|
118
|
+
// ?access_token= / &token= / ?token= / &access_token=
|
|
119
|
+
.replace(/([?&](?:access_token|token)=)[^&\s#]+/gi, `$1${_REDACTED}`)
|
|
120
|
+
// Raw provider token prefixes (GitHub PATs, Anthropic keys). Keep prefix.
|
|
121
|
+
.replace(/\b(ghp_|gho_|ghu_|ghs_|github_pat_|sk-ant-)[A-Za-z0-9_-]+/g, `$1${_REDACTED}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── secure filesystem writes ────────────────────────────────────────────────
|
|
125
|
+
// Audit logs, scan state, and any file that may carry finding text or secrets
|
|
126
|
+
// must be owner-only. openSync's mode argument is still masked by the process
|
|
127
|
+
// umask, so writeSecure ALSO chmods explicitly — the file is 0600 regardless of
|
|
128
|
+
// the ambient umask. secureDirMode is the matching 0700 for any parent dir we
|
|
129
|
+
// have to create.
|
|
130
|
+
export const secureFileMode = 0o600;
|
|
131
|
+
export const secureDirMode = 0o700;
|
|
132
|
+
|
|
133
|
+
export function writeSecure(filePath, data) {
|
|
134
|
+
const dir = path.dirname(filePath);
|
|
135
|
+
if (!fs.existsSync(dir)) {
|
|
136
|
+
fs.mkdirSync(dir, { recursive: true, mode: secureDirMode });
|
|
137
|
+
try { fs.chmodSync(dir, secureDirMode); } catch { /* best-effort */ }
|
|
138
|
+
}
|
|
139
|
+
const fd = fs.openSync(filePath, 'w', secureFileMode);
|
|
140
|
+
try {
|
|
141
|
+
fs.writeSync(fd, typeof data === 'string' ? data : String(data ?? ''));
|
|
142
|
+
} finally {
|
|
143
|
+
fs.closeSync(fd);
|
|
144
|
+
}
|
|
145
|
+
// Force the mode down even if umask loosened it at creation time.
|
|
146
|
+
fs.chmodSync(filePath, secureFileMode);
|
|
147
|
+
return filePath;
|
|
148
|
+
}
|
package/src/util/yaml.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Thin wrapper over js-yaml.
|
|
2
|
+
//
|
|
3
|
+
// js-yaml 5 throws on empty input ("expected a document, but the input is
|
|
4
|
+
// empty") where 4.x returned undefined. Every YAML file this scanner reads is
|
|
5
|
+
// user-authored config — .agentic-security/rules.yml, suppressions.yml,
|
|
6
|
+
// profiles, policies — and an empty or fully commented-out config is a normal,
|
|
7
|
+
// intentional state, not an error. Without this shim a blank rules.yml prints a
|
|
8
|
+
// spurious parse error on every scan.
|
|
9
|
+
//
|
|
10
|
+
// Import this instead of js-yaml directly anywhere config is read.
|
|
11
|
+
import * as _yaml from 'js-yaml';
|
|
12
|
+
|
|
13
|
+
/** Parse YAML, returning undefined for blank/comment-only input (4.x behaviour). */
|
|
14
|
+
export function load(text, opts) {
|
|
15
|
+
if (typeof text !== 'string') return undefined;
|
|
16
|
+
// A document that is only whitespace and/or comments has no content. js-yaml
|
|
17
|
+
// 4 returned undefined here; preserve that rather than surfacing a throw.
|
|
18
|
+
const stripped = text.replace(/^\s*#.*$/gm, '').trim();
|
|
19
|
+
if (stripped === '') return undefined;
|
|
20
|
+
return _yaml.load(text, opts);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const dump = _yaml.dump;
|
|
24
|
+
export const CORE_SCHEMA = _yaml.CORE_SCHEMA;
|