@clear-capabilities/agentic-security-scanner 0.195.27 → 0.195.29

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 (38) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/bin/agentic-security.js +41 -4
  3. package/dist/5856.index.js +634 -0
  4. package/dist/7033.index.js +74 -14
  5. package/dist/9220.index.js +24 -1
  6. package/dist/agentic-security.mjs +9 -9
  7. package/dist/agentic-security.mjs.sha256 +1 -1
  8. package/dist/{90ebba38d8860ec77376.json → c06a1b4c20155c06fb48.json} +4 -3
  9. package/package.json +4 -3
  10. package/src/dataflow/catalog.js +42 -1
  11. package/src/engine.js +91 -11
  12. package/src/language/CLAUDE.md +7 -1
  13. package/src/language/assurance.js +53 -2
  14. package/src/language/haskell-budget-state.js +85 -0
  15. package/src/language/haskell-coverage.js +230 -0
  16. package/src/language/haskell-guards.js +26 -5
  17. package/src/language/haskell-project-models.js +107 -0
  18. package/src/language/nix-eval-isolation.js +74 -14
  19. package/src/llm-validator/bounded-context.js +193 -0
  20. package/src/llm-validator/claim-groups.js +105 -0
  21. package/src/llm-validator/disposition.js +264 -0
  22. package/src/llm-validator/index.js +52 -26
  23. package/src/pipeline/evidence-record.js +112 -0
  24. package/src/pipeline/scan-health.js +12 -1
  25. package/src/posture/artifact-registry.js +2 -0
  26. package/src/posture/drift.js +19 -1
  27. package/src/posture/reachability-filter.js +3 -3
  28. package/src/posture/suppressions.js +21 -0
  29. package/src/posture/watch-mode.js +22 -1
  30. package/src/report/disposition-view.js +90 -0
  31. package/src/report/index.js +47 -0
  32. package/src/runScan.js +60 -6
  33. package/src/sast/oidc-trust.js +74 -0
  34. package/src/sast/pipeline.js +14 -1
  35. package/src/sast/workflow-flow.js +272 -0
  36. package/src/sca/basis.js +88 -0
  37. package/src/scope/flags.js +55 -0
  38. package/src/scope/inventory.js +241 -0
@@ -1152,7 +1152,7 @@ const real = (p) => { try { return (0,external_node_fs_.realpathSync)(p); } catc
1152
1152
  * The sandboxed command for a backend. Reads are an ALLOWLIST (the listed roots, system library paths and the
1153
1153
  * evaluator); writes are an allowlist of one scratch directory; network and process creation are denied.
1154
1154
  * @param {{backend:string, path?:string}} sb
1155
- * @param {{execFile:string, args:string[], readOnly:string[], readFiles?:string[], writable:string[], cwd:string}} spec
1155
+ * @param {{execFile:string, args:string[], readOnly:string[], readFiles?:string[], writable:string[], cwd:string, env?:Record<string,string>}} spec
1156
1156
  */
1157
1157
  function sandboxCommand(sb, spec) {
1158
1158
  const ro = [...new Set(spec.readOnly.map(real))];
@@ -1175,34 +1175,94 @@ function sandboxCommand(sb, spec) {
1175
1175
  for (const p of ro) a.push('--ro-bind', p, p);
1176
1176
  for (const p of files) a.push('--ro-bind', p, p);
1177
1177
  for (const p of rw) a.push('--bind', p, p);
1178
+ // --clearenv empties the environment bwrap itself was given, so the clean one is set explicitly inside.
1179
+ for (const [k, v] of Object.entries(spec.env || {})) a.push('--setenv', k, String(v));
1180
+ // Namespaces do not stop process creation; the seccomp filter does. Before it, a sandboxed process could spawn anything the
1181
+ // read-only binds expose (found the first time a CI runner had bwrap installed). On an architecture with no filter there is
1182
+ // none, and the probe then reports the spawn and refuses to evaluate.
1183
+ const seccomp = seccompNoProcesses(sb.arch || process.arch);
1184
+ if (seccomp) a.push('--seccomp', '3');
1178
1185
  a.push('--chdir', spec.cwd, exec, ...spec.args);
1179
- return { file: sb.path, args: a, profile: null };
1186
+ return { file: sb.path, args: a, profile: null, fd3: seccomp };
1180
1187
  }
1181
1188
  return null;
1182
1189
  }
1183
1190
 
1191
+ // ── seccomp: threads yes, processes no ──────────────────────────────────────
1192
+ // A classic-BPF seccomp program, as `bwrap --seccomp FD` reads it (struct sock_filter, native little-endian). It denies fork and vfork,
1193
+ // denies clone unless CLONE_THREAD is set (so pthread_create works and posix_spawn, fork and vfork through clone do not), answers clone3
1194
+ // with ENOSYS (its flags are behind a pointer a filter cannot read; glibc then falls back to clone), denies x32 syscalls, and kills a
1195
+ // process that switches to another syscall ABI.
1196
+ const SECCOMP_ARCH = {
1197
+ x64: { audit: 0xc000003e, clone: 56, clone3: 435, deny: [57, 58], x32: true },
1198
+ arm64: { audit: 0xc00000b7, clone: 220, clone3: 435, deny: [], x32: false },
1199
+ };
1200
+ const CLONE_THREAD = 0x10000;
1201
+ function seccompNoProcesses(arch) {
1202
+ const A = SECCOMP_ARCH[arch];
1203
+ if (!A) return null;
1204
+ const LD = 0x20, JEQ = 0x15, JGE = 0x35, JSET = 0x45, RET = 0x06;
1205
+ const ALLOW = 0x7fff0000, EPERM = 0x00050000 | 1, ENOSYS = 0x00050000 | 38, KILL = 0x80000000;
1206
+ // Instructions with symbolic jump targets, resolved to relative offsets below.
1207
+ const prog = [
1208
+ [LD, 0, 0, 4], // arch
1209
+ [JEQ, null, 'kill', A.audit],
1210
+ [LD, 0, 0, 0], // syscall number
1211
+ ...(A.x32 ? [[JGE, 'eperm', null, 0x40000000]] : []),
1212
+ [JEQ, 'enosys', null, A.clone3],
1213
+ ...A.deny.map((nr) => [JEQ, 'eperm', null, nr]),
1214
+ [JEQ, null, 'allow', A.clone],
1215
+ [LD, 0, 0, 16], // clone flags, args[0] low word
1216
+ [JSET, 'allow', 'eperm', CLONE_THREAD],
1217
+ ['allow', RET, ALLOW], ['eperm', RET, EPERM], ['enosys', RET, ENOSYS], ['kill', RET, KILL],
1218
+ ];
1219
+ const at = {};
1220
+ prog.forEach((ins, i) => { if (typeof ins[0] === 'string') at[ins[0]] = i; });
1221
+ const buf = Buffer.alloc(prog.length * 8);
1222
+ prog.forEach((ins, i) => {
1223
+ const [code, jt, jf, k] = typeof ins[0] === 'string' ? [ins[1], 0, 0, ins[2]] : ins;
1224
+ const rel = (t) => (t === null || t === 0 ? 0 : at[t] - i - 1);
1225
+ buf.writeUInt16LE(code, i * 8); buf.writeUInt8(rel(jt), i * 8 + 2); buf.writeUInt8(rel(jf), i * 8 + 3); buf.writeUInt32LE(k >>> 0, i * 8 + 4);
1226
+ });
1227
+ return buf;
1228
+ }
1229
+
1184
1230
  // ── process supervisor ───────────────────────────────────────────────────────
1185
- function rssOfGroup(pgid) {
1186
- const r = (0,external_node_child_process_.spawnSync)('/bin/ps', ['-o', 'rss=', '-g', String(pgid)], { encoding: 'utf8', timeout: 2000 });
1231
+ // The memory of the whole process tree under `root`, in MB. Not the process group: bwrap's --new-session puts the sandboxed process in
1232
+ // a session and group of its own, so a group sum never saw the evaluator on Linux.
1233
+ function rssOfTree(root) {
1234
+ const r = (0,external_node_child_process_.spawnSync)('/bin/ps', ['-A', '-o', 'pid=,ppid=,rss='], { encoding: 'utf8', timeout: 2000 });
1187
1235
  if (r.status !== 0) return 0;
1188
- return r.stdout.split('\n').map((x) => parseInt(x, 10)).filter(Number.isFinite).reduce((a, b) => a + b, 0) / 1024;
1236
+ const kids = new Map(); const rss = new Map();
1237
+ for (const line of r.stdout.split('\n')) {
1238
+ const [pid, ppid, kb] = line.trim().split(/\s+/).map((x) => parseInt(x, 10));
1239
+ if (!Number.isFinite(pid) || !Number.isFinite(ppid)) continue;
1240
+ rss.set(pid, Number.isFinite(kb) ? kb : 0);
1241
+ if (!kids.has(ppid)) kids.set(ppid, []);
1242
+ kids.get(ppid).push(pid);
1243
+ }
1244
+ let total = 0; const seen = new Set(); const stack = [root];
1245
+ while (stack.length) { const p = stack.pop(); if (seen.has(p)) continue; seen.add(p); total += rss.get(p) || 0; stack.push(...(kids.get(p) || [])); }
1246
+ return total / 1024;
1189
1247
  }
1190
1248
  function killGroup(pid, sig) { try { process.kill(-pid, sig); } catch { try { process.kill(pid, sig); } catch { /* gone */ } } }
1191
1249
 
1192
1250
  /** Run a command to completion under a deadline, an output cap and a memory ceiling. Never throws. */
1193
- function supervise(file, args, { env, cwd, deadlineMs, maxOutputBytes, maxRssMb, killGraceMs = 1500 }) {
1251
+ function supervise(file, args, { env, cwd, deadlineMs, maxOutputBytes, maxRssMb, killGraceMs = 1500, fd3 = null }) {
1194
1252
  return new Promise((resolveP) => {
1195
1253
  const t0 = Date.now();
1196
1254
  let child;
1197
- try { child = (0,external_node_child_process_.spawn)(file, args, { env, cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] }); }
1255
+ try { child = (0,external_node_child_process_.spawn)(file, args, { env, cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe', ...(fd3 ? ['pipe'] : [])] }); }
1198
1256
  catch (e) { return resolveP({ status: 'failed', reason: `could not start: ${e.message}`, stdout: '', stderr: '', exitCode: null, signal: null, ms: 0 }); }
1257
+ // fd 3 carries the seccomp program to bwrap; a child that exits before reading it closes the pipe, which is not an error here.
1258
+ if (fd3) { child.stdio[3].on('error', () => {}); child.stdio[3].end(fd3); }
1199
1259
  let out = ''; let err = ''; let bytes = 0; let state = null; let done = false;
1200
1260
  const finish = (extra = {}) => { if (done) return; done = true; clearInterval(poll); clearTimeout(dl); resolveP({ stdout: out, stderr: err.slice(0, 4000), ms: Date.now() - t0, ...extra }); };
1201
1261
  const stop = (why) => { if (state) return; state = why; killGroup(child.pid, 'SIGTERM'); setTimeout(() => killGroup(child.pid, 'SIGKILL'), killGraceMs).unref(); };
1202
1262
  child.stdout.on('data', (d) => { bytes += d.length; if (bytes > maxOutputBytes) { stop('output-limit'); return; } out += d.toString('utf8'); });
1203
1263
  child.stderr.on('data', (d) => { if (err.length < 8000) err += d.toString('utf8'); });
1204
1264
  const dl = setTimeout(() => stop('timed-out'), deadlineMs);
1205
- const poll = setInterval(() => { if (!state && maxRssMb && rssOfGroup(child.pid) > maxRssMb) stop('resource-limit'); }, 250);
1265
+ const poll = setInterval(() => { if (!state && maxRssMb && rssOfTree(child.pid) > maxRssMb) stop('resource-limit'); }, 250);
1206
1266
  child.on('error', (e) => finish({ status: 'failed', reason: e.message, exitCode: null, signal: null }));
1207
1267
  child.on('close', (code, signal) => {
1208
1268
  killGroup(child.pid, 'SIGKILL'); // reap any descendants
@@ -1280,8 +1340,8 @@ async function probeSandbox(sb, opts = {}) {
1280
1340
  const port = tcp.address().port;
1281
1341
  try {
1282
1342
  const exe = opts.nodePath || process.execPath;
1283
- const cmd = sandboxCommand(sb, { execFile: exe, args: [script, (0,external_node_path_.join)(allowed, 'a.txt'), (0,external_node_path_.join)(outside, 'secret.txt'), (0,external_node_path_.join)(outside, 'w.txt'), (0,external_node_path_.join)(scratch, 'w.txt'), String(port), sockPath], readOnly: [allowed, (0,external_node_path_.dirname)((0,external_node_path_.dirname)(real(exe)))], readFiles: [script], writable: [scratch], cwd: scratch });
1284
- const r = await supervise(cmd.file, cmd.args, { env: cleanEnv(scratch), cwd: scratch, deadlineMs: limits.probeDeadlineMs, maxOutputBytes: 64 * 1024, maxRssMb: limits.maxRssMb, killGraceMs: limits.killGraceMs });
1343
+ const cmd = sandboxCommand(sb, { env: cleanEnv(scratch), execFile: exe, args: [script, (0,external_node_path_.join)(allowed, 'a.txt'), (0,external_node_path_.join)(outside, 'secret.txt'), (0,external_node_path_.join)(outside, 'w.txt'), (0,external_node_path_.join)(scratch, 'w.txt'), String(port), sockPath], readOnly: [allowed, (0,external_node_path_.dirname)((0,external_node_path_.dirname)(real(exe)))], readFiles: [script], writable: [scratch], cwd: scratch });
1344
+ const r = await supervise(cmd.file, cmd.args, { fd3: cmd.fd3, env: cleanEnv(scratch), cwd: scratch, deadlineMs: limits.probeDeadlineMs, maxOutputBytes: 64 * 1024, maxRssMb: limits.maxRssMb, killGraceMs: limits.killGraceMs });
1285
1345
  let attempts = null;
1286
1346
  try { attempts = JSON.parse(r.stdout.trim()); } catch { /* unparseable: not verified */ }
1287
1347
  if (!attempts) return { verified: false, backend: sb.backend, reason: `the probe did not complete inside the sandbox (${r.reason || r.stderr.slice(0, 120) || 'no output'})`, attempts: {} };
@@ -1304,8 +1364,8 @@ async function probeSandbox(sb, opts = {}) {
1304
1364
  /** @param {{file:string, prefixArgs?:string[]}} nix the evaluator command (a stand-in in tests) */
1305
1365
  async function detectEvaluator(nix, sb, ctx) {
1306
1366
  const run = async (args) => {
1307
- const cmd = sandboxCommand(sb, { execFile: nix.file, args: [...(nix.prefixArgs || []), ...args], readOnly: ctx.readOnly, readFiles: [...(ctx.readFiles || []), ...(nix.prefixArgs || []).filter((a) => !a.startsWith('-'))], writable: [ctx.work], cwd: ctx.work });
1308
- return supervise(cmd.file, cmd.args, { env: cleanEnv(ctx.work), cwd: ctx.work, deadlineMs: 10_000, maxOutputBytes: 64 * 1024, maxRssMb: ctx.limits.maxRssMb, killGraceMs: ctx.limits.killGraceMs });
1367
+ const cmd = sandboxCommand(sb, { env: cleanEnv(ctx.work), execFile: nix.file, args: [...(nix.prefixArgs || []), ...args], readOnly: ctx.readOnly, readFiles: [...(ctx.readFiles || []), ...(nix.prefixArgs || []).filter((a) => !a.startsWith('-'))], writable: [ctx.work], cwd: ctx.work });
1368
+ return supervise(cmd.file, cmd.args, { fd3: cmd.fd3, env: cleanEnv(ctx.work), cwd: ctx.work, deadlineMs: 10_000, maxOutputBytes: 64 * 1024, maxRssMb: ctx.limits.maxRssMb, killGraceMs: ctx.limits.killGraceMs });
1309
1369
  };
1310
1370
  const v = await run(['--version']);
1311
1371
  const ver = (/(\d+\.\d+(?:\.\d+)?)/.exec(v.stdout) || [])[1] || null;
@@ -1359,8 +1419,8 @@ async function runIsolatedEval(o) {
1359
1419
  const feat = await detectEvaluator(o.nix, sb, ctx);
1360
1420
  if (!feat.ok) return stopBefore('unsupported', `${feat.reason}; the project's Nix code was NOT evaluated`, { sandbox: sb, probe: { verified: true }, evaluator: feat });
1361
1421
  const args = [...(o.nix.prefixArgs || []), ...buildEvalArgs(target)];
1362
- const cmd = sandboxCommand(sb, { execFile: o.nix.file, args, readOnly: ctx.readOnly, readFiles: nixFiles, writable: [ctx.work], cwd: ctx.work });
1363
- const r = await supervise(cmd.file, cmd.args, { env: cleanEnv(ctx.work, o.env || {}), cwd: ctx.work, deadlineMs: limits.deadlineMs, maxOutputBytes: limits.maxOutputBytes, maxRssMb: limits.maxRssMb, killGraceMs: limits.killGraceMs });
1422
+ const cmd = sandboxCommand(sb, { env: cleanEnv(ctx.work, o.env || {}), execFile: o.nix.file, args, readOnly: ctx.readOnly, readFiles: nixFiles, writable: [ctx.work], cwd: ctx.work });
1423
+ const r = await supervise(cmd.file, cmd.args, { fd3: cmd.fd3, env: cleanEnv(ctx.work, o.env || {}), cwd: ctx.work, deadlineMs: limits.deadlineMs, maxOutputBytes: limits.maxOutputBytes, maxRssMb: limits.maxRssMb, killGraceMs: limits.killGraceMs });
1364
1424
  const base = { ...keep, projectCodeEvaluated: true, sandbox: { backend: sb.backend }, evaluator: { version: feat.version }, ms: r.ms, target: { attribute: target.attribute, system: target.system, installable: target.installable } };
1365
1425
  if (r.status !== 'ok') return { ...base, status: r.status === 'failed' ? 'failed' : r.status, reason: r.reason || r.stderr.slice(0, 200) };
1366
1426
  let data;
@@ -7,6 +7,7 @@ export const modules = {
7
7
 
8
8
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
9
9
  /* harmony export */ computeDelta: () => (/* binding */ computeDelta),
10
+ /* harmony export */ createScopeFilter: () => (/* binding */ createScopeFilter),
10
11
  /* harmony export */ persistStatus: () => (/* binding */ persistStatus),
11
12
  /* harmony export */ renderStatusLine: () => (/* binding */ renderStatusLine),
12
13
  /* harmony export */ watchProject: () => (/* binding */ watchProject)
@@ -17,6 +18,7 @@ export const modules = {
17
18
  /* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(76760);
18
19
  /* harmony import */ var _state_dir_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(31174);
19
20
  /* harmony import */ var _language_discovery_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(30951);
21
+ /* harmony import */ var _scope_inventory_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(58585);
20
22
  // Watch mode — continuous incremental scan as the developer edits.
21
23
  //
22
24
  // Spawns a long-running scan watcher that:
@@ -44,6 +46,7 @@ export const modules = {
44
46
 
45
47
 
46
48
 
49
+
47
50
  const STATUS_MD = 'watch-status.md';
48
51
  const STATUS_JSON = 'watch-status.json';
49
52
  const DEBOUNCE_MS = 350;
@@ -52,6 +55,25 @@ const MAX_BURST = 50; // ignore beyond this # of rapid events
52
55
  const SCAN_EXT_RE = /\.(?:[jt]sx?|mjs|cjs|py|java|kt|go|rb|php|cs|c|cc|cpp|h|hpp|rs|sol|vy|swift|dart|toml|yml|yaml|json|tf|tfvars|bicep)$/i;
53
56
  const IGNORE_DIR_RE = /(?:^|\/)(?:\.git|node_modules|\.bench-cache|dist|build|\.next|coverage|\.agentic-security)(?:$|\/)/;
54
57
 
58
+ // Under Git scope (PRD FR 01) the watcher asks the same inventory the scan uses, so a change to a Git-ignored file does not
59
+ // trigger a rescan, and a tracked file under a directory the default list would skip (`dist/`, `build/`) still does. A change
60
+ // to an ignore source rebuilds the inventory. A path the inventory has not seen (a new file) rebuilds it, at most once a second.
61
+ const IGNORE_SOURCE = /(?:^|\/)\.gitignore$|^\.git\/info\/exclude$/;
62
+ function createScopeFilter(scanRoot, scope = {}, { build = _scope_inventory_js__WEBPACK_IMPORTED_MODULE_5__/* .buildScanInventory */ .mP, now = Date.now } = {}) {
63
+ let included = null;
64
+ let builtAt = -Infinity;
65
+ const rebuild = () => { included = new Set(build(scanRoot, scope).included); builtAt = now(); };
66
+ rebuild();
67
+ return (rel) => {
68
+ if (!rel || /^\.agentic-security(?:\/|$)/.test(rel)) return false;
69
+ if (IGNORE_SOURCE.test(rel)) { rebuild(); return true; }
70
+ if (/^\.git(?:\/|$)/.test(rel)) return false;
71
+ if (included.has(rel)) return true;
72
+ if (now() - builtAt >= 1000) rebuild();
73
+ return included.has(rel);
74
+ };
75
+ }
76
+
55
77
  function _isScanable(rel) {
56
78
  if (!rel) return false;
57
79
  // An explicit export is evidence even though it lives in a build directory.
@@ -181,6 +203,7 @@ function readStatus(scanRoot) {
181
203
  async function watchProject(scanRoot, onChange, opts = {}) {
182
204
  if (process.env.AGENTIC_SECURITY_NO_WATCH === '1') return { stop: async () => {}, _disabled: true };
183
205
  const recursive = opts.recursive !== false;
206
+ const eligible = typeof opts.isEligible === 'function' ? opts.isEligible : _isScanable;
184
207
  const ac = new AbortController();
185
208
  let timer = null;
186
209
  const pending = new Set();
@@ -202,7 +225,7 @@ async function watchProject(scanRoot, onChange, opts = {}) {
202
225
  for await (const evt of node_fs_promises__WEBPACK_IMPORTED_MODULE_0__.watch(scanRoot, { recursive, signal: ac.signal })) {
203
226
  if (stopped) break;
204
227
  const rel = String(evt.filename || '').replace(/\\/g, '/');
205
- if (!_isScanable(rel)) continue;
228
+ if (!eligible(rel)) continue;
206
229
  pending.add(node_path__WEBPACK_IMPORTED_MODULE_2__.join(scanRoot, rel));
207
230
  if (timer) clearTimeout(timer);
208
231
  timer = setTimeout(flush, DEBOUNCE_MS);