@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.
- package/CHANGELOG.md +16 -0
- package/dist/{312ef9976a988b322ade.json → 13e6d1463f0aec440afa.json} +3 -3
- package/dist/agentic-security.mjs +4 -4
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/engine.js +6 -1
- package/src/language/CLAUDE.md +6 -3
- package/src/language/haskell-fix.js +108 -2
- package/src/language/haskell-ir.js +135 -8
- package/src/language/haskell-models.js +9 -2
- package/src/language/haskell-security-rules.js +82 -7
|
@@ -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
|
-
|
|
450
|
-
const
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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 });
|