@clear-capabilities/agentic-security-scanner 0.187.0 → 0.188.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 +9 -0
- package/dist/{deabc47d0e27e21bf99b.json → 92f78fd3e2dbe30fb58d.json} +3 -3
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/dataflow/CLAUDE.md +13 -0
- package/src/dataflow/catalog.js +14 -0
- package/src/dataflow/engine.js +131 -10
- package/src/engine.js +11 -3
- package/src/language/CLAUDE.md +7 -0
- package/src/language/haskell-comments.js +65 -0
- package/src/language/haskell-findings.js +2 -1
- package/src/language/haskell-fix.js +118 -2
- package/src/language/haskell-guards.js +2 -2
- package/src/language/haskell-ir.js +46 -3
- package/src/language/haskell-models.js +15 -4
- package/src/language/haskell-security-rules.js +113 -9
- package/src/language/haskell-trust.js +268 -0
- package/src/language/haskell-web.js +2 -1
- package/src/sast/cloud-iam.js +3 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Haskell comments, found lexically. Used so that text a finding carries from the source (its snippet, and through it the identity and the
|
|
2
|
+
// clone-shape of the finding) is the CODE of the line and never a comment on it: a verdict, an id and a cluster must not change because someone
|
|
3
|
+
// edited, or lied in, a comment. Strings, character literals and pragmas are not comments; a run of dashes inside an operator (`-->`, `|--`) is
|
|
4
|
+
// not a comment; block comments nest.
|
|
5
|
+
|
|
6
|
+
const SYMBOL = new Set([...'!#$%&*+./<=>?@\\^|~:-']);
|
|
7
|
+
const IDENT = /[A-Za-z0-9_']/;
|
|
8
|
+
|
|
9
|
+
/** The comments of a Haskell source: [{start, end, kind}] with `end` exclusive. */
|
|
10
|
+
export function findComments(src) {
|
|
11
|
+
const out = [];
|
|
12
|
+
const n = src.length;
|
|
13
|
+
let i = 0;
|
|
14
|
+
while (i < n) {
|
|
15
|
+
const c = src[i];
|
|
16
|
+
if (c === '"') { // string literal
|
|
17
|
+
i++;
|
|
18
|
+
while (i < n && src[i] !== '"') { if (src[i] === '\\') i++; i++; }
|
|
19
|
+
i++;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (c === "'" && !(i > 0 && IDENT.test(src[i - 1]))) { // character literal (a prime after an identifier character is part of the name)
|
|
23
|
+
const m = /^'(?:\\(?:[^']|x[0-9A-Fa-f]+|[0-9]+|[A-Z]+)|[^'\\])'/.exec(src.slice(i, i + 12));
|
|
24
|
+
if (m) { i += m[0].length; continue; }
|
|
25
|
+
}
|
|
26
|
+
if (c === '{' && src[i + 1] === '-' && src[i + 2] !== '#') {
|
|
27
|
+
let depth = 1; const start = i; i += 2;
|
|
28
|
+
while (i < n && depth > 0) {
|
|
29
|
+
if (src[i] === '{' && src[i + 1] === '-') { depth++; i += 2; } else if (src[i] === '-' && src[i + 1] === '}') { depth--; i += 2; } else i++;
|
|
30
|
+
}
|
|
31
|
+
out.push({ start, end: i, kind: 'block' });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (c === '-' && src[i + 1] === '-') {
|
|
35
|
+
let j = i; while (j < n && src[j] === '-') j++;
|
|
36
|
+
const prevSym = i > 0 && SYMBOL.has(src[i - 1]) && src[i - 1] !== '-';
|
|
37
|
+
const nextSym = j < n && SYMBOL.has(src[j]);
|
|
38
|
+
if (!prevSym && !nextSym) {
|
|
39
|
+
let e = j; while (e < n && src[e] !== '\n') e++;
|
|
40
|
+
out.push({ start: i, end: e, kind: 'line' });
|
|
41
|
+
i = e;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
i = j;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const blank = (text) => text.replace(/[^\n]/g, ' ');
|
|
53
|
+
|
|
54
|
+
/** The source with every comment overwritten by spaces: same length, same line breaks, so locations do not move. */
|
|
55
|
+
export function stripComments(src) {
|
|
56
|
+
let out = ''; let at = 0;
|
|
57
|
+
for (const c of findComments(src)) { out += src.slice(at, c.start) + blank(src.slice(c.start, c.end)); at = c.end; }
|
|
58
|
+
return out + src.slice(at);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One source line without its comments, trimmed. A block comment that opened on an earlier line is not known here; a line that is only a comment becomes ''. */
|
|
62
|
+
export function codeOfLine(line) {
|
|
63
|
+
if (typeof line !== 'string') return '';
|
|
64
|
+
return stripComments(line).trim();
|
|
65
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// finding is distinguishable, filterable and compliance-mappable without a second finding schema.
|
|
5
5
|
|
|
6
6
|
import { HS_MODEL_VERSION } from './haskell-models.js';
|
|
7
|
+
import { codeOfLine } from './haskell-comments.js';
|
|
7
8
|
|
|
8
9
|
const HS_FILE = /\.(?:hs|lhs|hsc)$/i;
|
|
9
10
|
|
|
@@ -32,7 +33,7 @@ export function annotateHaskellFindings(findings, fileContents = null) {
|
|
|
32
33
|
if (!f || !isHaskellFile(f.file)) continue;
|
|
33
34
|
if (!f.snippet && fileContents && typeof fileContents[f.file] === 'string' && Number.isInteger(f.line) && f.line >= 1) {
|
|
34
35
|
const text = fileContents[f.file].split('\n')[f.line - 1];
|
|
35
|
-
if (typeof text === 'string') f.snippet = text
|
|
36
|
+
if (typeof text === 'string') f.snippet = codeOfLine(text).slice(0, 240);
|
|
36
37
|
}
|
|
37
38
|
f.language = 'haskell';
|
|
38
39
|
f.capability = f.capability || 'taint';
|
|
@@ -124,7 +124,19 @@ function findCall(lineText, names) {
|
|
|
124
124
|
return m ? { prefix: m[1], name: m[2], index: m.index, after: m.index + m[0].length } : null;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// The database driver whose `execute`/`query`/`Only` the rewrite uses is the one the FILE already imports: a hard-coded driver would add an
|
|
128
|
+
// import of a library the project may not depend on, and a patch that compiles against nothing is not a fix. Only the two drivers whose
|
|
129
|
+
// `execute_`/`query_`/`execute`/`query`/`Only` API is identical are rewritten; any other (mysql-simple, HDBC, persistent) or two at once is refused.
|
|
130
|
+
const SQL_DRIVERS = Object.freeze(['Database.PostgreSQL.Simple', 'Database.SQLite.Simple']);
|
|
131
|
+
const OTHER_SQL = /^import\s+(?:qualified\s+)?Database\.(?:MySQL|HDBC|Persist|Beam|Opaleye|Selda|Esqueleto|SQLite3|PostgreSQL\.LibPQ)/m;
|
|
132
|
+
export function sqlDriverOf(text) {
|
|
133
|
+
const used = SQL_DRIVERS.filter((m) => importInfo(text, m));
|
|
134
|
+
return used.length === 1 && !OTHER_SQL.test(text) ? used[0] : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
127
137
|
function fixSql(text, f) {
|
|
138
|
+
const driver = sqlDriverOf(text);
|
|
139
|
+
if (!driver) return null;
|
|
128
140
|
const lb = lineBounds(text, f.line);
|
|
129
141
|
const call = findCall(lb.text, ['execute_', 'query_']);
|
|
130
142
|
if (!call) return null;
|
|
@@ -144,7 +156,7 @@ function fixSql(text, f) {
|
|
|
144
156
|
if (q) { sql = sql.slice(0, -1); parts[i + 1] = { lit: after.lit.slice(1) }; }
|
|
145
157
|
sql += '?'; params.push(p.var);
|
|
146
158
|
}
|
|
147
|
-
const imp = ensureImport(text,
|
|
159
|
+
const imp = ensureImport(text, driver, ['execute', 'query', 'Only']);
|
|
148
160
|
if (!imp) return null;
|
|
149
161
|
const only = `${call.prefix || imp.prefix}Only`;
|
|
150
162
|
const tuple = params.length === 1 ? `(${only} ${params[0]})` : `(${params.join(', ')})`;
|
|
@@ -214,6 +226,9 @@ function fixProcess(text, f) {
|
|
|
214
226
|
}
|
|
215
227
|
|
|
216
228
|
function fixHtml(text, f) {
|
|
229
|
+
// `preEscapedToHtml` and `toHtml` are blaze-html's pair (Yesod and the shakespeare packages re-export them); another library's raw-markup call has a
|
|
230
|
+
// different name and a different escaping function, so a file that does not import blaze or one of its re-exporters is not rewritten.
|
|
231
|
+
if (!/^import\s+(?:qualified\s+)?(?:Text\.Blaze|Yesod|Text\.Hamlet)/m.test(text)) return null;
|
|
217
232
|
const lb = lineBounds(text, f.line);
|
|
218
233
|
const m = /(?<![\w'.])((?:[A-Z][\w']*\.)?)preEscapedToHtml(?![\w'])/.exec(lb.text);
|
|
219
234
|
if (!m) return null;
|
|
@@ -227,12 +242,98 @@ function fixWeakHash(text, f) {
|
|
|
227
242
|
const lb = lineBounds(text, f.line);
|
|
228
243
|
const re = /(?<![\w'.])(MD5|SHA1)(?![\w'.])/g;
|
|
229
244
|
if (!re.test(lb.text)) return null;
|
|
245
|
+
// The algorithm is often a TYPE as well (`Digest MD5` in a signature, an annotation on another line): replacing it on one line only leaves a
|
|
246
|
+
// type error behind. Any other mention outside the import list means the rewrite is not provably the same program, so it is not made.
|
|
247
|
+
const elsewhere = text.split('\n').some((l, i) => i !== f.line - 1 && !/^import\s/.test(l) && /(?<![\w'.])(MD5|SHA1)(?![\w'.])/.test(l));
|
|
248
|
+
if (elsewhere) return null;
|
|
230
249
|
const imp = ensureImport(text, 'Crypto.Hash', ['SHA256(..)']); if (!imp) return null;
|
|
231
250
|
const repl = lb.text.replace(/(?<![\w'.])(MD5|SHA1)(?![\w'.])/g, 'SHA256');
|
|
232
251
|
const next = applyImportDelta(text, imp.text, splice(text, lb.start, lb.end, repl));
|
|
233
252
|
return { ruleId: 'hs-hash-sha256', label: 'FULL', text: next, explanation: 'MD5/SHA-1 replaced by SHA-256. Digests change: anything that stored or compares old digests must be migrated.', behavior: { kind: 'hash', from: ['MD5', 'SHA1'], to: 'SHA256' } };
|
|
234
253
|
}
|
|
235
254
|
|
|
255
|
+
|
|
256
|
+
// ── path traversal ───────────────────────────────────────────────────────────
|
|
257
|
+
// `readFile ("/srv/data/" ++ name)` -> `readFile ("/srv/data/" ++ takeFileName name)` when `name` is a String/FilePath PARAMETER of the enclosing function.
|
|
258
|
+
// The rewrite is a mitigation, not an equivalence: a name that carried directories now keeps only its last segment, and `.` / `..` survive
|
|
259
|
+
// `takeFileName`, which is why directory operations are not rewritten (only operations on a file). Every other shape is a proposal.
|
|
260
|
+
const PATH_FILE_OPS = ['readFile', 'writeFile', 'appendFile', 'removeFile', 'doesFileExist', 'openFile', 'withFile', 'openBinaryFile', 'withBinaryFile', 'getFileSize', 'getModificationTime'];
|
|
261
|
+
|
|
262
|
+
/** Splits `a ++ b </> c` at its top-level `++` / `<>` / `</>` operators, keeping the operator text: [part, op, part, ...] or null. */
|
|
263
|
+
function splitOperators(expr) {
|
|
264
|
+
const out = []; let depth = 0; let start = 0;
|
|
265
|
+
for (let i = 0; i < expr.length; i++) {
|
|
266
|
+
const c = expr[i];
|
|
267
|
+
if (c === '"') { const e = atomEnd(expr, i); if (e < 0) return null; i = e - 1; continue; }
|
|
268
|
+
if (c === '(' || c === '[') depth++;
|
|
269
|
+
else if (c === ')' || c === ']') depth--;
|
|
270
|
+
else if (depth === 0) {
|
|
271
|
+
const m = /^\s*(\+\+|<>|<\/>)\s*/.exec(expr.slice(i));
|
|
272
|
+
if (m && (i === start || /\s/.test(expr[i]) || true)) { out.push(expr.slice(start, i), m[0]); i += m[0].length - 1; start = i + 1; }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
out.push(expr.slice(start));
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The declared types of the parameters of the top-level function containing `line`: { name -> type text }, from its signature and first clause. */
|
|
280
|
+
function paramTypesAt(text, line) {
|
|
281
|
+
const lines = text.split('\n');
|
|
282
|
+
let clause = -1;
|
|
283
|
+
for (let i = Math.min(line - 1, lines.length - 1); i >= 0; i--) if (/^[a-z_][\w']*\b[^:\n]*=/.test(lines[i]) && !/^[a-z_][\w']*\s*::/.test(lines[i])) { clause = i; break; }
|
|
284
|
+
if (clause < 0) return null;
|
|
285
|
+
const m = /^([a-z_][\w']*)([^=]*)=/.exec(lines[clause]);
|
|
286
|
+
if (!m) return null;
|
|
287
|
+
const name = m[1]; const params = m[2].trim() ? m[2].trim().split(/\s+/) : [];
|
|
288
|
+
if (params.some((p) => !/^[a-z_][\w']*$/.test(p))) return null; // a pattern, not a plain name: not judged
|
|
289
|
+
const sigLine = lines.findIndex((l) => new RegExp(`^${name}\\s*::`).test(l));
|
|
290
|
+
if (sigLine < 0) return null;
|
|
291
|
+
let sig = lines[sigLine].replace(/^[^:]*::/, '');
|
|
292
|
+
for (let j = sigLine + 1; j < lines.length && /^\s+\S/.test(lines[j]) && !/^[a-z_][\w']*\s*(::|=)/.test(lines[j]); j++) sig += ` ${lines[j].trim()}`;
|
|
293
|
+
sig = sig.replace(/^.*=>/, '');
|
|
294
|
+
const types = []; let depth = 0; let cur = '';
|
|
295
|
+
for (let i = 0; i < sig.length; i++) {
|
|
296
|
+
const c = sig[i];
|
|
297
|
+
if (c === '(' || c === '[') depth++; else if (c === ')' || c === ']') depth--;
|
|
298
|
+
if (depth === 0 && sig.startsWith('->', i)) { types.push(cur.trim()); cur = ''; i++; continue; }
|
|
299
|
+
cur += c;
|
|
300
|
+
}
|
|
301
|
+
types.push(cur.trim());
|
|
302
|
+
const out = {};
|
|
303
|
+
params.forEach((p, i) => { if (types[i] !== undefined) out[p] = types[i]; });
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function fixPath(text, f) {
|
|
308
|
+
const lb = lineBounds(text, f.line);
|
|
309
|
+
const call = findCall(lb.text, PATH_FILE_OPS);
|
|
310
|
+
if (!call) return null;
|
|
311
|
+
let aStart = call.after; while (lb.text[aStart] === ' ') aStart++;
|
|
312
|
+
let aEnd;
|
|
313
|
+
if (lb.text[aStart] === '$') { aStart++; while (lb.text[aStart] === ' ') aStart++; aEnd = lb.text.length; }
|
|
314
|
+
else aEnd = atomEnd(lb.text, aStart);
|
|
315
|
+
if (aEnd < 0) return null;
|
|
316
|
+
const arg = lb.text.slice(aStart, aEnd);
|
|
317
|
+
const inner = trimParens(arg);
|
|
318
|
+
const parts = splitOperators(inner);
|
|
319
|
+
if (!parts || parts.length < 3) return null;
|
|
320
|
+
const operands = parts.filter((_, i) => i % 2 === 0).map((x) => trimParens(x));
|
|
321
|
+
if (!operands.some((o) => STRING.test(o))) return null; // no fixed part: a pass-through, not a construction
|
|
322
|
+
const vars = operands.filter((o) => !STRING.test(o));
|
|
323
|
+
if (!vars.length || vars.some((v) => !IDENT.test(v))) return null;
|
|
324
|
+
const types = paramTypesAt(text, f.line);
|
|
325
|
+
if (!types || vars.some((v) => !/^(?:String|FilePath)$/.test(types[v] || ''))) return null; // takeFileName takes a String: any other type would not compile
|
|
326
|
+
if (new RegExp(`(?<![\\w'.])takeFileName\\s+(?:${vars.join('|')})(?![\\w'])`).test(lb.text)) return null;
|
|
327
|
+
const imp = ensureImport(text, 'System.FilePath', ['takeFileName']); if (!imp) return null;
|
|
328
|
+
const fn = `${imp.prefix}takeFileName`;
|
|
329
|
+
let rebuilt = '';
|
|
330
|
+
parts.forEach((part, i) => { rebuilt += i % 2 === 1 ? part : (vars.includes(trimParens(part)) ? part.replace(trimParens(part), `${fn} ${trimParens(part)}`) : part); });
|
|
331
|
+
const wrapped = arg.startsWith('(') && atomEnd(arg, 0) === arg.length ? `(${rebuilt})` : rebuilt;
|
|
332
|
+
const replaced = lb.text.slice(0, aStart) + wrapped + lb.text.slice(aEnd);
|
|
333
|
+
const next = applyImportDelta(text, imp.text, splice(text, lb.start, lb.end, replaced));
|
|
334
|
+
return { ruleId: 'hs-path-takefilename', label: 'MITIGATION', text: next, explanation: `Applied takeFileName to ${vars.join(', ')} so only its last path segment is used under the fixed directory. A value that carried subdirectories now names a file directly inside it; "." and ".." survive takeFileName, so this is not applied to directory operations.`, behavior: { kind: 'path', sanitizer: 'takeFileName', vars } };
|
|
335
|
+
}
|
|
336
|
+
|
|
236
337
|
const LOG_CALLS = ['putStrLn', 'putStr', 'hPutStrLn', 'hPutStr', 'print', 'trace', 'traceShow', 'traceShowId', 'logInfoN', 'logDebugN', 'logWarnN', 'logErrorN', 'logInfo', 'logDebug', 'logWarn', 'logError'];
|
|
237
338
|
function fixLogging(text, f) {
|
|
238
339
|
const lb = lineBounds(text, f.line);
|
|
@@ -256,6 +357,7 @@ const FIXERS = [
|
|
|
256
357
|
{ id: 'hs-html-escape', applies: (f) => f.cwe === 'CWE-79', run: fixHtml },
|
|
257
358
|
{ id: 'hs-hash-sha256', applies: (f) => f.cwe === 'CWE-328' || f.rule === 'hs-weak-hash', run: fixWeakHash },
|
|
258
359
|
{ id: 'hs-log-redact', applies: (f) => f.cwe === 'CWE-532' || f.rule === 'hs-sensitive-logging', run: fixLogging },
|
|
360
|
+
{ id: 'hs-path-takefilename', applies: (f) => f.cwe === 'CWE-22', run: fixPath },
|
|
259
361
|
];
|
|
260
362
|
export const SUPPORTED_FIX_IDS = Object.freeze(FIXERS.map((x) => x.id));
|
|
261
363
|
|
|
@@ -300,9 +402,23 @@ export function planHaskellFix(finding, files) {
|
|
|
300
402
|
if (r && r.text && r.text !== before) return { ok: true, ruleId: r.ruleId, label: r.label, file: finding.file, before, after: r.text, explanation: r.explanation, behavior: r.behavior, ...(line !== finding.line ? { fixedAtLine: line } : {}) };
|
|
301
403
|
}
|
|
302
404
|
}
|
|
303
|
-
return { ok: false, reason: 'no deterministic fix is supported for this exact shape', proposal: 'model-assisted' };
|
|
405
|
+
return { ok: false, reason: CLASS_REASONS[finding.cwe] || 'no deterministic fix is supported for this exact shape', proposal: 'model-assisted' };
|
|
304
406
|
}
|
|
305
407
|
|
|
408
|
+
// Classes whose repair needs a choice the code does not contain (a policy, a library, a limit). The scanner reports them; it does not guess.
|
|
409
|
+
const CLASS_REASONS = Object.freeze({
|
|
410
|
+
'CWE-918': 'server-side request forgery is fixed by an allow-list of hosts or a resolved-address policy, which is a decision about this service, not a rewrite of this line',
|
|
411
|
+
'CWE-88': 'argument injection is fixed by validating the value against what the program accepts; inserting "--" only helps programs that honour it, so no patch is made',
|
|
412
|
+
'CWE-916': 'password hashing needs a memory-hard or iterated KDF (Argon2id, scrypt, bcrypt, PBKDF2) from a library the project may not depend on, and existing stored hashes must be migrated',
|
|
413
|
+
'CWE-338': 'a predictable generator is replaced by the operating system CSPRNG (Crypto.Random), whose result type differs from System.Random: the surrounding code must change with it',
|
|
414
|
+
'CWE-1004': 'cookie attributes live in different places for each framework (a record field, a header string, a helper); the rewrite is not provably the same program',
|
|
415
|
+
'CWE-306': 'a missing authentication check needs a policy (which credential, which rejection); the scanner reports the handler and does not invent the guard',
|
|
416
|
+
'CWE-770': 'a resource limit is a number only the operator can choose (how many bytes, how many requests)',
|
|
417
|
+
'CWE-295': 'enabling certificate validation can break connections the project depends on; the trust store to use is a deployment decision',
|
|
418
|
+
'CWE-798': 'a hard-coded secret must be rotated and moved to a secret store; rewriting the line would leave the leaked value valid',
|
|
419
|
+
'CWE-611': 'XML entity handling depends on the parser configuration in use',
|
|
420
|
+
});
|
|
421
|
+
|
|
306
422
|
// ── gates ────────────────────────────────────────────────────────────────────
|
|
307
423
|
const key = (f) => `${f.file}|${f.cwe || ""}`;
|
|
308
424
|
|
|
@@ -30,7 +30,7 @@ const FAMILY_BY_CWE = { 'CWE-22': 'path', 'CWE-918': 'url', 'CWE-78': 'cmd', 'CW
|
|
|
30
30
|
|
|
31
31
|
const isStr = (e) => e && e.kind === 'literal' && typeof e.value === 'string';
|
|
32
32
|
|
|
33
|
-
function identNames(e, out = new Set(), depth = 0) {
|
|
33
|
+
export function identNames(e, out = new Set(), depth = 0) {
|
|
34
34
|
if (!e || typeof e !== 'object' || depth > 30) return out;
|
|
35
35
|
if (e.kind === 'ident' && e.name && !(e.hs && e.hs.functionRef)) out.add(e.name);
|
|
36
36
|
for (const k of ['left', 'right', 'object', 'value', 'callee']) if (e[k] && typeof e[k] === 'object') identNames(e[k], out, depth + 1);
|
|
@@ -118,7 +118,7 @@ const exprChildren = (e) => {
|
|
|
118
118
|
};
|
|
119
119
|
|
|
120
120
|
// Find the sink call at `line` with callee `callee`; returns [{ nodeId, facts, call }].
|
|
121
|
-
function findSites(fn, callee, line) {
|
|
121
|
+
export function findSites(fn, callee, line) {
|
|
122
122
|
const sites = [];
|
|
123
123
|
const visit = (e, nodeId, facts, depth) => {
|
|
124
124
|
if (!e || typeof e !== 'object' || depth > 60) return;
|
|
@@ -264,6 +264,7 @@ function resolveGlobalUncached(proj, mod, name, qual, depth, seen) {
|
|
|
264
264
|
const hits = [];
|
|
265
265
|
const wildcard = [];
|
|
266
266
|
let unknownReason = null;
|
|
267
|
+
let unknownCandidates = null;
|
|
267
268
|
for (const imp of visibleImports(mod, name, qual)) {
|
|
268
269
|
const target = proj.modules.get(imp.module);
|
|
269
270
|
if (target) {
|
|
@@ -274,7 +275,7 @@ function resolveGlobalUncached(proj, mod, name, qual, depth, seen) {
|
|
|
274
275
|
const owner = r.kind === 'def' ? (r.def.kind === 'field' ? (proj.modules.get(r.mod)?.cons.get(r.def.con)?.type) : r.def.kind === 'method' ? r.def.cls : null) : null;
|
|
275
276
|
if (!owner || !(imp.typeAll || []).includes(owner)) continue;
|
|
276
277
|
}
|
|
277
|
-
if (r.kind === 'unknown') unknownReason = r.reason; else hits.push(r);
|
|
278
|
+
if (r.kind === 'unknown') { unknownReason = r.reason; if (r.candidates) unknownCandidates = r.candidates; } else hits.push(r);
|
|
278
279
|
} else if (qual || (imp.items && !imp.hiding && imp.items.includes(name))) hits.push({ kind: 'external', module: imp.module, name, certain: true });
|
|
279
280
|
else wildcard.push(imp.module); // includes `import M (T(..))` of a module we cannot read: possible, never certain
|
|
280
281
|
}
|
|
@@ -282,7 +283,7 @@ function resolveGlobalUncached(proj, mod, name, qual, depth, seen) {
|
|
|
282
283
|
const distinct = [...new Map(hits.map((r) => [keyOf(r), r])).values()];
|
|
283
284
|
if (distinct.length === 1) return distinct[0];
|
|
284
285
|
if (distinct.length > 1) return { kind: 'unknown', reason: 'ambiguous-import', candidates: distinct.map(keyOf) };
|
|
285
|
-
if (unknownReason) return { kind: 'unknown', reason: unknownReason };
|
|
286
|
+
if (unknownReason) return unknownCandidates ? { kind: 'unknown', reason: unknownReason, candidates: unknownCandidates } : { kind: 'unknown', reason: unknownReason };
|
|
286
287
|
if (!qual && PRELUDE.has(name)) return { kind: 'builtin', module: 'Prelude', name };
|
|
287
288
|
if (wildcard.length) return { kind: 'external', module: wildcard.length === 1 ? wildcard[0] : null, candidates: wildcard, name, certain: false };
|
|
288
289
|
return { kind: 'unknown', reason: qual ? 'unknown-qualifier' : 'unbound-name' };
|
|
@@ -584,6 +585,7 @@ class FnLowerer {
|
|
|
584
585
|
return { kind: 'ident', name: this.canonical(r), hs: { functionRef: r.qid } };
|
|
585
586
|
}
|
|
586
587
|
if (r.kind === 'param' || r.kind === 'local') return { kind: 'ident', name: e.name };
|
|
588
|
+
if (r.kind === 'unknown' && r.reason === 'ambiguous-import') for (const q of r.candidates || []) this.noteRef(q, 'ambiguous-import', ln);
|
|
587
589
|
const lib = libraryName(r, e.name, e.qual);
|
|
588
590
|
const viaReg = lib.viaRegistry ? { viaRegistry: true, certain: false } : {};
|
|
589
591
|
// A modelled SOURCE action named as a value (`getLine >>= f`, `fmap g getLine`) yields untrusted data
|
|
@@ -725,6 +727,10 @@ class FnLowerer {
|
|
|
725
727
|
if (IO_NAMES.has(f.name)) this.sawIO = true;
|
|
726
728
|
} else {
|
|
727
729
|
hs.status = 'unknown'; hs.reason = r.reason || 'unresolved'; hs.effect = 'unknown';
|
|
730
|
+
// An import that two modules both satisfy (the Posix and Windows variants of one module behind a CPP conditional, two re-exports of
|
|
731
|
+
// one name) is a call to ONE of them, which one is not decided here. Each candidate is referenced, so none of them reads as a
|
|
732
|
+
// function nothing in the project uses.
|
|
733
|
+
if (r.reason === 'ambiguous-import') for (const q of r.candidates || []) this.noteRef(q, 'ambiguous-import', ln);
|
|
728
734
|
}
|
|
729
735
|
if (hs.status === 'resolved' || hs.status === 'typeclass') {
|
|
730
736
|
const ctxTargets = args.map((a) => argTargets(a));
|
|
@@ -1335,6 +1341,7 @@ function lowerTopFunction(proj, mod, fun, def) {
|
|
|
1335
1341
|
const ann = callerControlledParams(fn, sig, exported, mod.imports.map((i) => i.module));
|
|
1336
1342
|
for (const a of servantEndpointParams(mod, fn, sig, exported)) if (!ann.some((x) => x.name === a.name)) ann.push(a);
|
|
1337
1343
|
fn.hs.textParams = ann.map((a) => a.name);
|
|
1344
|
+
fn.hs.paramSources = ann.map((a) => ({ name: a.name, decorator: a.decorator }));
|
|
1338
1345
|
fn.hs.inputParams = inputParams(fn, sig, exported);
|
|
1339
1346
|
// Parameters of an exported function whose type is a record DECLARED IN THE PROJECT: the lineage view treats each such
|
|
1340
1347
|
// parameter as a source of that record's fields (a customer record handed to an entry point), field by field.
|
|
@@ -1454,7 +1461,13 @@ function buildHaskellIRUncached(fileContents) {
|
|
|
1454
1461
|
const reached = new Set();
|
|
1455
1462
|
for (const e of callGraph.edges || []) if (e && e.callee) reached.add(e.callee);
|
|
1456
1463
|
for (const fn of callGraph.functions.values()) for (const r of (fn.hs && fn.hs.refs) || []) if (r && r.target) reached.add(r.target);
|
|
1457
|
-
|
|
1464
|
+
assignEntryRoles(proj, callGraph);
|
|
1465
|
+
for (const fn of callGraph.functions.values()) {
|
|
1466
|
+
// `reached`: something in the analysed code calls or references this function, so its parameters take their values from THOSE callers
|
|
1467
|
+
// (which the taint engine follows) and not from an unknown outside one. Rules that reason about "caller-supplied" text read it.
|
|
1468
|
+
if (fn.hs) fn.hs.reached = reached.has(fn.qid);
|
|
1469
|
+
if (reached.has(fn.qid)) { if (fn.paramAnnotations) delete fn.paramAnnotations; if (fn.hs && fn.hs.recordParams) fn.hs.recordParams = []; }
|
|
1470
|
+
}
|
|
1458
1471
|
return { perFile, callGraph, modules: proj.modules, diagnostics: proj.diagnostics, project: proj };
|
|
1459
1472
|
}
|
|
1460
1473
|
|
|
@@ -1468,6 +1481,36 @@ function incomplete(proj, mod, f, err) {
|
|
|
1468
1481
|
proj.diagnostics.push({ file: mod.file, line: f.line, kind: 'ir-lowering-failure', detail: String(err && err.message) });
|
|
1469
1482
|
}
|
|
1470
1483
|
|
|
1484
|
+
// ── entry roles ──────────────────────────────────────────────────────────────
|
|
1485
|
+
// Who is on the other end of the program's inputs depends on what kind of program the function belongs to. A function reachable from a
|
|
1486
|
+
// `main` whose reachable modules import no server framework or socket library belongs to a command-line program: whoever runs it supplies its
|
|
1487
|
+
// arguments, environment, standard input and the files it is pointed at. One reachable from a `main` that imports a server framework (a WAI,
|
|
1488
|
+
// Scotty, Servant, Yesod, Snap, Happstack or Spock application, a socket listener) belongs to a service, whose inputs come from its clients.
|
|
1489
|
+
// A function no `main` reaches is `library`: its callers are unknown. The call graph is incomplete (an unresolved call ends a path), so a
|
|
1490
|
+
// function the walk does not reach is `library`, never `cli`.
|
|
1491
|
+
const SERVER_IMPORT = /^(?:Network\.Wai|Web\.Scotty|Servant|Yesod|Snap|Happstack\.Server|Web\.Spock)(?:\.|$)|^Network\.(?:Socket|Simple\.TCP|Run\.TCP|HTTP\.Server)$/;
|
|
1492
|
+
export function assignEntryRoles(proj, callGraph) {
|
|
1493
|
+
const out = new Map();
|
|
1494
|
+
for (const e of callGraph.edges || []) if (e && e.callee && e.caller) { if (!out.has(e.caller)) out.set(e.caller, []); out.get(e.caller).push(e.callee); }
|
|
1495
|
+
const mains = [];
|
|
1496
|
+
for (const fn of callGraph.functions.values()) if (fn.hs && fn.hs.module && fn.name === `${fn.hs.module}.main`) mains.push(fn);
|
|
1497
|
+
const cli = new Set(); const service = new Set();
|
|
1498
|
+
for (const m of mains) {
|
|
1499
|
+
const seen = new Set([m.qid]);
|
|
1500
|
+
const todo = [m.qid];
|
|
1501
|
+
while (todo.length) { const q = todo.pop(); for (const c of out.get(q) || []) if (!seen.has(c)) { seen.add(c); todo.push(c); } }
|
|
1502
|
+
const mods = new Set();
|
|
1503
|
+
for (const q of seen) { const f = callGraph.functions.get(q); if (f && f.hs && f.hs.module) mods.add(f.hs.module); }
|
|
1504
|
+
let isService = false;
|
|
1505
|
+
for (const name of mods) { const mi = proj.modules.get(name); if (mi && (mi.imports || []).some((i) => SERVER_IMPORT.test(i.module))) { isService = true; break; } }
|
|
1506
|
+
for (const q of seen) (isService ? service : cli).add(q);
|
|
1507
|
+
}
|
|
1508
|
+
for (const fn of callGraph.functions.values()) {
|
|
1509
|
+
if (!fn.hs) continue;
|
|
1510
|
+
fn.hs.entryRole = service.has(fn.qid) ? 'service' : (cli.has(fn.qid) ? 'cli' : 'library');
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1471
1514
|
// ── call graph ───────────────────────────────────────────────────────────────
|
|
1472
1515
|
function buildHaskellCallGraph(perFile, proj) {
|
|
1473
1516
|
const functions = new Map();
|
|
@@ -73,6 +73,7 @@ export const HS_PACKAGES = Object.freeze({
|
|
|
73
73
|
HsOpenSSL: { modules: ['OpenSSL.Session'], tested: [] },
|
|
74
74
|
'wai-extra': { modules: ['Network.Wai.Parse', 'Network.Wai.Conduit'], tested: [] },
|
|
75
75
|
'scotty-cookie': { modules: ['Web.Scotty.Cookie'], tested: [] },
|
|
76
|
+
ihp: { modules: ['IHP.ControllerPrelude', 'IHP.Controller.Param'], tested: [] },
|
|
76
77
|
'http-types': { modules: ['Network.HTTP.Types', 'Network.HTTP.Types.Header'], tested: [] },
|
|
77
78
|
'conduit-extra': { modules: ['Data.Conduit.Binary', 'Data.Conduit.Combinators'], tested: [] },
|
|
78
79
|
'uri-parsing': { modules: ['Text.URI'], tested: [] },
|
|
@@ -85,10 +86,16 @@ const SRC = [];
|
|
|
85
86
|
const src = (module, names, provenance, label, extra = {}) => { for (const e of E(module, names)) SRC.push({ ...e, provenance, label: `${label}${names.length > 1 ? '' : ''}`, package: extra.package, framework: extra.framework || null }); };
|
|
86
87
|
|
|
87
88
|
src('Prelude', ['getLine', 'getContents', 'readLn', 'interact'], 'stdin', 'standard input', { package: 'base' });
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
src('
|
|
91
|
-
src('
|
|
89
|
+
// `getLine` and `getContents` read standard input. `hGetLine`/`hGetContents`/`hGetChar` read WHATEVER handle they are given (a file the program opened,
|
|
90
|
+
// a child process's pipe, a socket, or stdin), so they are modelled as a read through a handle, not as standard input.
|
|
91
|
+
src('System.IO', ['getLine', 'getContents'], 'stdin', 'handle input', { package: 'base' });
|
|
92
|
+
src('System.IO', ['hGetLine', 'hGetContents', 'hGetChar'], 'file-read', 'handle input', { package: 'base' });
|
|
93
|
+
src('Data.Text.IO', ['getLine', 'getContents', 'interact'], 'stdin', 'text input', { package: 'text' });
|
|
94
|
+
src('Data.Text.IO', ['hGetLine', 'hGetContents'], 'file-read', 'text input', { package: 'text' });
|
|
95
|
+
src('Data.ByteString', ['getLine', 'getContents'], 'stdin', 'bytes input', { package: 'bytestring' });
|
|
96
|
+
src('Data.ByteString', ['hGetLine', 'hGetContents'], 'file-read', 'bytes input', { package: 'bytestring' });
|
|
97
|
+
src('Data.ByteString.Char8', ['getLine', 'getContents'], 'stdin', 'bytes input', { package: 'bytestring' });
|
|
98
|
+
src('Data.ByteString.Char8', ['hGetLine', 'hGetContents'], 'file-read', 'bytes input', { package: 'bytestring' });
|
|
92
99
|
src('System.Environment', ['getArgs'], 'cli', 'command-line argument', { package: 'base' });
|
|
93
100
|
src('System.Environment', ['getEnv', 'lookupEnv', 'getEnvironment'], 'env', 'environment variable', { package: 'base' });
|
|
94
101
|
src('Prelude', ['readFile'], 'file-read', 'file content', { package: 'base' });
|
|
@@ -144,6 +151,10 @@ for (const mod of ['Web.Scotty', 'Web.Scotty.Trans']) {
|
|
|
144
151
|
src(mod, ['bodyReader', 'bodyRaw'], 'http-body', 'scotty request body', { package: 'scotty', framework: 'scotty' });
|
|
145
152
|
}
|
|
146
153
|
src('Web.Scotty.Cookie', ['getCookie', 'getCookies'], 'cookie', 'scotty request cookie', { package: 'scotty-cookie', framework: 'scotty' });
|
|
154
|
+
// IHP: a controller action reads the request with `param @Text "name"` (a pure function over the implicit request): the value is whatever the client sent.
|
|
155
|
+
for (const mod of ['IHP.ControllerPrelude', 'IHP.Controller.Param']) {
|
|
156
|
+
src(mod, ['param', 'paramText', 'paramOrNothing', 'paramOrDefault', 'paramList'], 'url-param', 'IHP request parameter', { package: 'ihp', framework: 'ihp' });
|
|
157
|
+
}
|
|
147
158
|
for (const mod of ['Yesod.Core', 'Yesod']) {
|
|
148
159
|
src(mod, ['parseJsonBody', 'requireJsonBody', 'parseCheckJsonBody', 'requireCheckJsonBody', 'lookupCookies', 'lookupHeaders'], 'http-body', 'yesod request body', { package: 'yesod', framework: 'yesod' });
|
|
149
160
|
src(mod, ['lookupBasicAuth'], 'header', 'yesod request header', { package: 'yesod', framework: 'yesod' });
|