@clear-capabilities/agentic-security-scanner 0.128.1 → 0.132.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/bin/agentic-security.js +52 -2
  3. package/dist/11.index.js +2 -2
  4. package/dist/113.index.js +498 -7
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +220 -0
  7. package/dist/238.index.js +218 -0
  8. package/dist/259.index.js +975 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/415.index.js +1 -1
  11. package/dist/435.index.js +4 -4
  12. package/dist/526.index.js +844 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/830.index.js +1 -1
  15. package/dist/agentic-security.mjs +106 -194
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +33 -17
  18. package/src/dataflow/CLAUDE.md +4 -1
  19. package/src/dataflow/async-sequencing.js +8 -3
  20. package/src/dataflow/catalog.js +278 -11
  21. package/src/dataflow/cross-repo.js +1 -1
  22. package/src/dataflow/cross-service-taint.js +1 -1
  23. package/src/dataflow/engine.js +182 -61
  24. package/src/dataflow/ifds.js +10 -5
  25. package/src/dataflow/index.js +15 -3
  26. package/src/dataflow/points-to.js +8 -2
  27. package/src/dataflow/proof-gate.js +7 -0
  28. package/src/dataflow/sanitizer-gate.js +89 -0
  29. package/src/dataflow/tabulation.js +14 -3
  30. package/src/engine.js +170 -7
  31. package/src/integrations/index.js +1 -1
  32. package/src/ir/CLAUDE.md +49 -4
  33. package/src/ir/call-sites.js +66 -0
  34. package/src/ir/callgraph.js +174 -7
  35. package/src/ir/class-hierarchy.js +22 -2
  36. package/src/ir/index.js +138 -51
  37. package/src/ir/ir-stats.js +126 -0
  38. package/src/ir/parser-cpp.js +829 -0
  39. package/src/ir/parser-cs.js +4 -1
  40. package/src/ir/parser-go.js +4 -1
  41. package/src/ir/parser-js.js +13 -1
  42. package/src/ir/parser-kt.js +4 -1
  43. package/src/ir/parser-php.js +10 -3
  44. package/src/ir/parser-py-cst.js +62 -10
  45. package/src/ir/tree-sitter-loader.js +13 -1
  46. package/src/llm-validator/index.js +9 -2
  47. package/src/llm-validator/redact.js +157 -0
  48. package/src/mcp/tools.js +2 -2
  49. package/src/posture/CLAUDE.md +193 -1
  50. package/src/posture/accuracy-scorecard.js +317 -0
  51. package/src/posture/api-contract.js +1 -1
  52. package/src/posture/attestation.js +202 -0
  53. package/src/posture/auditor-walkthrough.js +12 -3
  54. package/src/posture/compliance-policy.js +1 -1
  55. package/src/posture/corpus-enroll.js +303 -0
  56. package/src/posture/corpus-match.js +52 -0
  57. package/src/posture/cross-lang-openapi.js +1 -1
  58. package/src/posture/custom-rules.js +3 -3
  59. package/src/posture/execution-proof.js +92 -0
  60. package/src/posture/exploitability-probability.js +1 -1
  61. package/src/posture/falsification.js +45 -1
  62. package/src/posture/fix-metrics.js +197 -0
  63. package/src/posture/fix-verify.js +129 -2
  64. package/src/posture/license-policy.js +1 -1
  65. package/src/posture/profile.js +1 -1
  66. package/src/posture/proof-tier.js +33 -0
  67. package/src/posture/relevance.js +379 -0
  68. package/src/posture/root-cause-sweep.js +0 -0
  69. package/src/posture/rule-overrides.js +1 -1
  70. package/src/posture/sca-policy.js +1 -1
  71. package/src/posture/scan-checkpoint.js +277 -0
  72. package/src/posture/suppressions.js +1 -1
  73. package/src/posture/test-runner.js +147 -0
  74. package/src/posture/verification-separation.js +131 -0
  75. package/src/report/index.js +11 -0
  76. package/src/runScan.js +5 -7
  77. package/src/sandbox/CLAUDE.md +340 -0
  78. package/src/sandbox/backend-disabled.js +14 -0
  79. package/src/sandbox/backend-namespace.js +335 -0
  80. package/src/sandbox/backend-userspace.js +83 -0
  81. package/src/sandbox/capabilities.js +181 -0
  82. package/src/sandbox/index.js +30 -0
  83. package/src/sandbox/limits.js +63 -0
  84. package/src/sandbox/result.js +104 -0
  85. package/src/sca/dep-confusion.js +1 -1
  86. package/src/util/glob.js +173 -0
  87. package/src/util/yaml.js +24 -0
@@ -0,0 +1,63 @@
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
+ import { spawnSync } from 'node:child_process';
8
+
9
+ // `ulimit -u` (RLIMIT_NPROC) is charged per *uid*, system-wide, on both
10
+ // platforms this module supports — it is not a per-process-tree cap. A fixed
11
+ // default like the 64 below therefore breaks ordinary, non-adversarial runs on
12
+ // any host whose user already owns more than ~64 processes, which is most of
13
+ // them: the confined shell cannot even fork the helpers it needs to set itself
14
+ // up, and the failure looks like a broken sandbox rather than a cap doing its
15
+ // job. So unless a caller passes an explicit `maxProcs`, both real backends
16
+ // derive one from the ambient count for this uid. That keeps default behaviour
17
+ // usable without pretending a low fixed cap is real containment — see the
18
+ // "fork-storm containment is weak" note in the module guide.
19
+ export function ambientRelativeMaxProcs(headroom = 64) {
20
+ let ambient = 200;
21
+ try {
22
+ const out = spawnSync('/bin/sh', ['-c', 'ps -U "$(id -un)" -o pid= | wc -l'], { encoding: 'utf8' });
23
+ ambient = Number(String(out.stdout || '').trim()) || 200;
24
+ } catch { /* fall through to the conservative default */ }
25
+ return ambient + headroom;
26
+ }
27
+
28
+ export function buildLimitPrelude({
29
+ maxProcs = 64,
30
+ maxFileSizeKb = 65536,
31
+ maxAddressSpaceKb = null,
32
+ } = {}) {
33
+ const parts = [];
34
+ const unsupported = [];
35
+
36
+ if (maxProcs != null) parts.push(`ulimit -u ${_num('maxProcs', maxProcs)}`);
37
+ if (maxFileSizeKb != null) parts.push(`ulimit -f ${_num('maxFileSizeKb', maxFileSizeKb)}`);
38
+
39
+ if (maxAddressSpaceKb != null) {
40
+ if (process.platform === 'linux') parts.push(`ulimit -v ${_num('maxAddressSpaceKb', maxAddressSpaceKb)}`);
41
+ else unsupported.push('maxAddressSpaceKb');
42
+ }
43
+
44
+ const prelude = parts.length ? parts.join('; ') + '; ' : '';
45
+ return { prelude, unsupported };
46
+ }
47
+
48
+ /**
49
+ * Limit values are interpolated into a shell fragment, so a non-numeric value
50
+ * is shell text. Verified by execution: `maxProcs: '999; echo INJECTED'`
51
+ * emitted `ulimit -u 999; echo INJECTED` and the payload ran. That is not a
52
+ * sandbox escape (the prelude runs INSIDE the confinement) but it lets a
53
+ * config-derived value silently DISABLE the very limits it was meant to set —
54
+ * e.g. `'0 2>/dev/null; true'` swallows the failure. Coerce and reject
55
+ * anything that is not a finite, non-negative number.
56
+ */
57
+ function _num(name, v) {
58
+ const n = Number(v);
59
+ if (!Number.isFinite(n) || n < 0) {
60
+ throw new RangeError(`${name} must be a finite, non-negative number (got ${JSON.stringify(v)})`);
61
+ }
62
+ return Math.floor(n);
63
+ }
@@ -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] 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
+ }
@@ -9,7 +9,7 @@
9
9
 
10
10
  import * as fs from 'node:fs';
11
11
  import * as path from 'node:path';
12
- import * as yaml from 'js-yaml';
12
+ import * as yaml from '../util/yaml.js';
13
13
  import { createRequire } from 'node:module';
14
14
 
15
15
  const _require = createRequire(import.meta.url);
@@ -0,0 +1,173 @@
1
+ // File discovery on top of the Node standard library only.
2
+ //
3
+ // Two entry points, both replacing a third-party glob package that used to be a
4
+ // production dependency:
5
+ //
6
+ // listFiles(root, {ignore}) — the scan-tree walk behind readScan/readTree.
7
+ // globFiles(pattern, {cwd}) — resolve a user-supplied glob to regular files.
8
+ //
9
+ // `globFiles` is a thin wrapper: `fs.promises.glob` matches the old behaviour
10
+ // for the way those call sites used it (dot:false, onlyFiles:true, symlinks
11
+ // followed), so all it adds is the files-only filter.
12
+ //
13
+ // `listFiles` cannot be a thin wrapper, and the reasons are worth stating
14
+ // precisely, because each one is a way file discovery could silently change:
15
+ //
16
+ // * dot — the built-in glob (like every minimatch-derived matcher) will not
17
+ // let `*` or `**` match a path segment starting with `.`, and exposes no
18
+ // option to change that. The scan tree must include hidden files and must
19
+ // descend into hidden directories, so the walk is done with fs.readdir
20
+ // instead, where visibility is simply not a concept.
21
+ // * onlyFiles — a glob walk yields directories as well; only regular files
22
+ // are wanted. readdir's Dirent gives that directly, with no extra stat.
23
+ // * followSymbolicLinks:false — the built-in glob follows symlinked
24
+ // directories with no option to stop it, which would let a link inside the
25
+ // tree pull unrelated content (or content outside the scan root) into the
26
+ // scan. readdir's Dirent is lstat-derived: a symlink reports isSymbolicLink
27
+ // and neither isDirectory nor isFile, so links are skipped by construction
28
+ // and their targets are never reached.
29
+ // * suppressErrors — an unreadable directory must not abort the walk. Each
30
+ // readdir is individually guarded.
31
+ // * ignore — patterns are still matched with the built-in path.matchesGlob;
32
+ // only the dot-blindness above is compensated for (see unhide()).
33
+ //
34
+ // Ordering matches the previous implementation's breadth-first shape: every
35
+ // entry of one depth is emitted before any entry of the next.
36
+ import * as fs from 'node:fs/promises';
37
+ import * as path from 'node:path';
38
+
39
+ // Two properties of path.matchesGlob have to be corrected, and both are
40
+ // corrected the same way: by rewriting the subject *and* the pattern through
41
+ // the same injective transform, so the built-in matcher does the actual glob
42
+ // work while the transform carries the semantics it will not.
43
+ //
44
+ // 1. Dot-blindness. A wildcard will not match a path segment starting with
45
+ // `.`, and there is no option to change that. Rewriting a segment's leading
46
+ // dot to a control character makes wildcards dot-permissive while keeping
47
+ // an explicitly-named segment literal: pattern `.git` becomes `<1>git`,
48
+ // which still matches a directory named `.git` and still does not match one
49
+ // named `git`. Only applied where dot-permissive matching is wanted.
50
+ // 2. Partial case-folding. The matcher folds case in some positions — e.g.
51
+ // `UPPER/MiXeD.JS` matches `**/*.js` — while `Test/a.js` does not match
52
+ // `**/test/**`. That inconsistency is worse than either rule on its own,
53
+ // and case-folding is not what the previous implementation did. Encoding
54
+ // each upper-case letter as a control character plus its lower-case form
55
+ // makes matching case-sensitive everywhere: no folded comparison can
56
+ // reintroduce the marker.
57
+ //
58
+ // Neither control character can appear in a filename produced by any of the
59
+ // filesystems this runs on, so the transform cannot collide with real content.
60
+ const DOT = String.fromCharCode(1);
61
+ const UP = String.fromCharCode(2);
62
+ const HAS_UPPER = /[A-Z]/;
63
+ const ALL_UPPER = /[A-Z]/g;
64
+ const foldUp = c => UP + c.toLowerCase();
65
+
66
+ function prepPath(p, dot) {
67
+ let s = p;
68
+ if (dot && s.includes('.')) {
69
+ const segs = s.split('/');
70
+ for (let i = 0; i < segs.length; i++) {
71
+ if (segs[i].charCodeAt(0) === 46) segs[i] = DOT + segs[i].slice(1);
72
+ }
73
+ s = segs.join('/');
74
+ }
75
+ return HAS_UPPER.test(s) ? s.replace(ALL_UPPER, foldUp) : s;
76
+ }
77
+
78
+ /**
79
+ * True when `rel` (a '/'-separated relative path) matches any of `patterns`.
80
+ * Case-sensitive; wildcards match hidden segments.
81
+ */
82
+ export function matchesAnyGlob(rel, patterns) {
83
+ if (!patterns || patterns.length === 0) return false;
84
+ const subject = prepPath(rel, true);
85
+ for (const pat of patterns) {
86
+ if (path.matchesGlob(subject, prepPath(pat, true))) return true;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ // A directory may be skipped wholesale only when a pattern provably excludes
92
+ // every possible descendant. `<prefix>/**` is exactly that shape: `**` absorbs
93
+ // any number of trailing segments, so if the directory matches `<prefix>` then
94
+ // nothing beneath it can survive. Anything else (`<prefix>/*`, `**/*.min.js`)
95
+ // may exclude some descendants and keep others, so those directories are walked
96
+ // and their files filtered individually. Pruning is therefore only ever a
97
+ // speed-up: correctness comes from the per-file check.
98
+ function prunable(subject, prepared) {
99
+ for (const p of prepared) {
100
+ if (p.subtree && path.matchesGlob(subject, p.subtree)) return true;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ function prepare(patterns) {
106
+ return patterns.map(pat => {
107
+ const glob = prepPath(pat, true);
108
+ return { glob, subtree: glob.endsWith('/**') ? glob.slice(0, -3) : null };
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Every regular file under `root`, as '/'-separated paths relative to `root`.
114
+ * Hidden entries included; directories and symlinks excluded; unreadable
115
+ * directories skipped; `ignore` globs applied to each file path.
116
+ */
117
+ export async function listFiles(root, { ignore = [] } = {}) {
118
+ const prepared = prepare(ignore);
119
+ const out = [];
120
+ let level = [''];
121
+ while (level.length) {
122
+ const next = [];
123
+ for (const dir of level) {
124
+ let entries;
125
+ try {
126
+ entries = await fs.readdir(dir ? path.join(root, dir) : root, { withFileTypes: true });
127
+ } catch {
128
+ continue; // unreadable or vanished — skip it, keep walking
129
+ }
130
+ for (const e of entries) {
131
+ const rel = dir ? `${dir}/${e.name}` : e.name;
132
+ const subject = prepared.length ? prepPath(rel, true) : '';
133
+ if (e.isDirectory()) {
134
+ if (!prunable(subject, prepared)) next.push(rel);
135
+ } else if (e.isFile()) {
136
+ let skip = false;
137
+ for (const p of prepared) {
138
+ if (path.matchesGlob(subject, p.glob)) { skip = true; break; }
139
+ }
140
+ if (!skip) out.push(rel);
141
+ }
142
+ // symlinks, sockets, fifos and block/char devices are not files
143
+ }
144
+ }
145
+ level = next;
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /**
151
+ * Resolve a user-supplied glob to regular files, relative to `cwd`. Hidden
152
+ * paths are not matched (the built-in default) and symlinks are followed, both
153
+ * matching the previous behaviour at these call sites.
154
+ *
155
+ * Two corrections are layered on the built-in walk: results are narrowed to
156
+ * regular files (a glob yields directories too), and the partial case-folding
157
+ * described above is undone by re-checking each result against the pattern
158
+ * case-sensitively — a `.SARIF` file must not answer to `*.sarif`. Symlink
159
+ * cycles are the one place this is deliberately not bug-compatible: the
160
+ * built-in stops at a cycle instead of re-enumerating the tree through it.
161
+ */
162
+ export async function globFiles(pattern, { cwd = process.cwd() } = {}) {
163
+ const strict = prepPath(pattern, false);
164
+ const out = [];
165
+ for await (const p of fs.glob(pattern, { cwd })) {
166
+ if (!path.matchesGlob(prepPath(p, false), strict)) continue;
167
+ try {
168
+ const st = await fs.stat(path.isAbsolute(p) ? p : path.join(cwd, p));
169
+ if (st.isFile()) out.push(p);
170
+ } catch { /* vanished or dangling link — not a file */ }
171
+ }
172
+ return out;
173
+ }
@@ -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;