@clear-capabilities/agentic-security-scanner 0.195.10 → 0.195.11

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.
@@ -87,6 +87,54 @@ const BOUNDING = /(?:^|\.)(?:take|takeE|isolate|hGetSome|hGet|splitAt|limit|limi
87
87
  const REQUEST_PROVENANCE = new Set(['url-param', 'path-param', 'http-body', 'header', 'cookie', 'upload']);
88
88
  // wai-extra's `requestSizeCheck (Just n) req` wraps the request so the body read after it fails past n bytes: a bound on every body read of the function.
89
89
  const WAI_SIZE_CHECK = /(?:^|\.)requestSizeCheck$/;
90
+ // scotty's global body limit: `setMaxRequestBodySize kb` in the ScottyM block answers every body read of the application it builds with a 413.
91
+ const SCOTTY_BODY_LIMIT = /^Web\.Scotty(?:\.Trans)?\.setMaxRequestBodySize$/;
92
+
93
+ // Request text handed to a project helper: in `deleteEnum tableId list`, where the caller's `tableId` was read from the request, the helper's
94
+ // first parameter carries caller text for the partial-function, allocation and bounding rules (which do not follow calls themselves). Decided
95
+ // from the IR's RESOLVED call targets and the caller's own request reads, one hop per round, bounded rounds; a parameter the IR could not name
96
+ // (`$arg0`, a pattern) is skipped. Returns qid -> Set of parameter names.
97
+ function requestTextParams(ir) {
98
+ const byQid = new Map();
99
+ for (const fir of Object.values(ir.perFile)) for (const fn of fir.functions) byQid.set(fn.qid, fn);
100
+ const out = new Map();
101
+ const requestRead = (c) => { const s = sourceInfo(c.callee); return !!s && REQUEST_PROVENANCE.has(s.provenance); };
102
+ const callsIn = (e) => { const r = []; for (const x of walk(e)) if (x.kind === 'call' && typeof x.callee === 'string') r.push(x); return r; };
103
+ for (let round = 0; round < 4; round++) {
104
+ let grew = false;
105
+ for (const fn of byQid.values()) {
106
+ const from = new Set(out.get(fn.qid) || []);
107
+ for (let k = 0; k < 8; k++) {
108
+ let g = false;
109
+ for (const n of fnNodes(fn)) {
110
+ if (n.kind !== 'assign' || !n.target || !n.source) continue;
111
+ if (!from.has(n.target) && (callsIn(n.source).some(requestRead) || [...names(n.source)].some((x) => from.has(x)))) { from.add(n.target); g = true; }
112
+ }
113
+ if (!g) break;
114
+ }
115
+ const derived = (e) => [...names(e)].some((x) => from.has(x)) || callsIn(e).some(requestRead);
116
+ const mark = (x) => {
117
+ if (!x || !x.hs) return;
118
+ // a call with every argument, or a partial application (`updateSchema (deleteEnum tableId)`: the IR keeps the supplied arguments as elements)
119
+ const target = x.kind === 'call' && x.hs.status === 'resolved' ? x.hs.target : (x.kind === 'array' && x.hs.partial ? x.hs.partial.target : null);
120
+ if (!target) return;
121
+ const callee = byQid.get(target);
122
+ if (!callee || callee === fn || !Array.isArray(callee.params)) return;
123
+ ((x.kind === 'call' ? x.args : x.elements) || []).forEach((a, i) => {
124
+ const p = callee.params[i];
125
+ if (!p || typeof p !== 'string' || p.startsWith('$') || !derived(a)) return;
126
+ if (!out.has(callee.qid)) out.set(callee.qid, new Set());
127
+ if (!out.get(callee.qid).has(p)) { out.get(callee.qid).add(p); grew = true; }
128
+ });
129
+ };
130
+ for (const n of fnNodes(fn)) for (const ex of [n.value, n.source, n.cond, ...(n.args || [])]) if (ex) for (const x of walk(ex)) mark(x);
131
+ // a helper the IR inlined at its call site keeps its edge, with the arguments, on `fn.calls`
132
+ for (const c of fn.calls || []) if (c && c.hs && c.hs.inlined) mark(c);
133
+ }
134
+ if (!grew) break;
135
+ }
136
+ return out;
137
+ }
90
138
  // `replicateM n getLine` reads n lines: the count is an allocation (and an input loop) sized by whoever supplied n.
91
139
  const ALLOCATORS = new Set(['replicate', 'Prelude.replicate', 'Data.List.replicate', 'Data.ByteString.replicate', 'Data.ByteString.Char8.replicate', 'Data.ByteString.Lazy.replicate', 'Data.Text.replicate', 'Data.Vector.replicate', 'Control.Monad.replicateM', 'Control.Monad.replicateM_', 'Data.Vector.replicateM', 'Data.List.genericReplicate']);
92
140
  const CLAMPS = /^(?:(?:Prelude|Data\.Ord)\.)?(?:min|clamp)$/;
@@ -391,6 +439,20 @@ export function analyzeHaskellRules(files, opts = {}) {
391
439
  if (/import\s+(?:qualified\s+)?Network\.Wai\.Middleware\.RequestSizeLimit\b/.test(text) || /\brequestSizeLimitMiddleware\b/.test(text)) { requestLimit = { file, line: (text.split('\n').findIndex((l) => /RequestSizeLimit|requestSizeLimitMiddleware/.test(l)) + 1) || 1 }; break; }
392
440
  }
393
441
 
442
+ const requestParams = requestTextParams(ir);
443
+ // A ScottyM block that calls setMaxRequestBodySize bounds every body read of the application it builds: certain for a body read in the SAME
444
+ // function (an inline route) and for a handler the block references; a block elsewhere in the project is a control whose reach is not verified
445
+ // (the finding stays, at low, as for the wai middleware).
446
+ const scottyLimitFns = [];
447
+ for (const fir of Object.values(ir.perFile)) for (const fn of fir.functions) if ((fn.calls || []).some((c) => typeof c.callee === 'string' && SCOTTY_BODY_LIMIT.test(c.callee))) scottyLimitFns.push(fn);
448
+ const scottyLimitFor = (fn) => {
449
+ if (!scottyLimitFns.length) return null;
450
+ if (scottyLimitFns.includes(fn)) return { certain: true, fn };
451
+ const refs = (g) => new Set([...((g.hs && g.hs.refs) || []).map((r) => r.target), ...(g.calls || []).map((c) => c.hs && c.hs.target).filter(Boolean)]);
452
+ const g = scottyLimitFns.find((x) => refs(x).has(fn.qid));
453
+ return g ? { certain: true, fn: g } : { certain: false, fn: scottyLimitFns[0] };
454
+ };
455
+
394
456
  let currentFn = null;
395
457
  const legacyMemo = new Map();
396
458
  const emit = (rule, file, line, extra = {}) => {
@@ -446,8 +508,10 @@ export function analyzeHaskellRules(files, opts = {}) {
446
508
  // A value READ from the request inside the function (`raw <- param "page"`, `body <- strictRequestBody req`, `lookup h (requestHeaders req)`)
447
509
  // is caller-supplied text wherever the function sits: the framework's own read says who supplied it, so no parameter label is needed.
448
510
  const requestRead = (c) => { const s = sourceInfo(c.callee); return !!s && REQUEST_PROVENANCE.has(s.provenance); };
449
- const derivedText = new Set([...(fnHs.textParams || []).filter((n) => strongParam.has(n) || entryLike), ...(entryLike ? (fnHs.inputParams || []) : [])]);
450
- const fromRequest = new Set();
511
+ // a parameter a caller fills with request-derived text (`requestTextParams`) is caller text here too, whatever this function's own label
512
+ const helperParams = requestParams.get(fn.qid) || new Set();
513
+ const derivedText = new Set([...(fnHs.textParams || []).filter((n) => strongParam.has(n) || entryLike), ...(entryLike ? (fnHs.inputParams || []) : []), ...helperParams]);
514
+ const fromRequest = new Set(helperParams);
451
515
  for (let round = 0; round < 8; round++) {
452
516
  let grew = false;
453
517
  for (const n of fnNodes(fn)) {
@@ -592,9 +656,14 @@ export function analyzeHaskellRules(files, opts = {}) {
592
656
  if (e.hs.con === 'VerifyNone' && e.kind === 'literal' && importsFrom(['OpenSSL.Session'])) emit('hs-tls-validation-disabled', file, e.line || node.line, { detail: 'OpenSSL verification mode VerifyNone', evidence: { mode: 'VerifyNone' } });
593
657
  }
594
658
  // `xs !! n` raises on a short list: a partial function, written as an operator, applied to caller-supplied text
595
- if (e.kind === 'binary' && e.op === '!!' && e.left && mentionsText(e.left) && !isTested([e.left])) {
596
- emit('hs-partial-function-on-input', file, e.line || node.line, { detail: '!! on a caller-supplied list', evidence: { partial: '!!' } });
597
- continue;
659
+ // (an INDEX from caller text is the same flaw, `list !! tableId`: out of range raises; a relational test on the index in this function covers it)
660
+ if (e.kind === 'binary' && e.op === '!!' && e.left && e.right) {
661
+ const listText = mentionsText(e.left) && !isTested([e.left]);
662
+ const indexText = !listText && mentionsText(e.right) && !isTested([e.right]) && ![...names(e.right)].some((v) => sizeTestedVars.has(v));
663
+ if (listText || indexText) {
664
+ emit('hs-partial-function-on-input', file, e.line || node.line, { detail: listText ? '!! on a caller-supplied list' : '!! with a caller-supplied index', evidence: { partial: '!!' }, inlineCtx: e.hs && e.hs.inlineCtx });
665
+ continue;
666
+ }
598
667
  }
599
668
  if (e.kind !== 'call' || typeof e.callee !== 'string') continue;
600
669
  const c = e.callee;
@@ -663,12 +732,18 @@ export function analyzeHaskellRules(files, opts = {}) {
663
732
  emit('hs-unbounded-allocation', file, line, { detail: `${c} sized by parsed caller text`, evidence: { allocator: c } });
664
733
  }
665
734
  // a partial function applied to caller text: one malformed value raises an uncaught exception
666
- if (PARTIALS.has(c) && args.some((a) => mentionsText(a)) && !isTested(args)) emit('hs-partial-function-on-input', file, line, { detail: `${c} on caller-supplied input`, evidence: { partial: c } });
735
+ if (PARTIALS.has(c) && args.some((a) => mentionsText(a)) && !isTested(args)) emit('hs-partial-function-on-input', file, line, { detail: `${c} on caller-supplied input`, evidence: { partial: c }, inlineCtx: e.hs && e.hs.inlineCtx });
667
736
  // a session/token cookie assembled without HttpOnly or Secure
668
737
  if (c === 'Web.Cookie.defaultSetCookie') { /* handled on the record update below */ }
669
738
  // unbounded body
670
739
  if (BODY_CALLS.has(c) && !sizeChecked && !(c === 'Network.Wai.Conduit.sourceRequestBody' && bounded)) {
671
- if (requestLimit) {
740
+ const scottyLimit = /^Web\.Scotty/.test(c) ? scottyLimitFor(fn) : null;
741
+ if (scottyLimit && scottyLimit.certain) {
742
+ controls.push({ kind: 'scotty-body-limit', file: scottyLimit.fn.file, line: scottyLimit.fn.line, appliesTo: `${file}:${line}`, verified: true });
743
+ } else if (scottyLimit) {
744
+ controls.push({ kind: 'scotty-body-limit', file: scottyLimit.fn.file, line: scottyLimit.fn.line, appliesTo: `${file}:${line}`, verified: false });
745
+ emit('hs-unbounded-request-body', file, line, { severity: 'low', confidence: 0.4, detail: c, uncertainty: [{ kind: 'unresolved-import', detail: `a scotty body limit is set (${scottyLimit.fn.file}:${scottyLimit.fn.line}); that this handler is registered in that application is not verified` }] });
746
+ } else if (requestLimit) {
672
747
  controls.push({ kind: 'request-size-limit', file: requestLimit.file, line: requestLimit.line, appliesTo: `${file}:${line}`, verified: false });
673
748
  emit('hs-unbounded-request-body', file, line, { severity: 'low', confidence: 0.4, detail: c, uncertainty: [{ kind: 'unresolved-import', detail: `a request-size-limit middleware exists (${requestLimit.file}:${requestLimit.line}); that it wraps this handler is not verified` }] });
674
749
  } else emit('hs-unbounded-request-body', file, line, { detail: c });