@clear-capabilities/agentic-security-scanner 0.172.1 → 0.174.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 +26 -0
- package/bin/agentic-security.js +37 -0
- package/dist/119.index.js +197 -0
- package/dist/128.index.js +129 -0
- package/dist/1293.index.js +5689 -0
- package/dist/1351.index.js +133 -0
- package/dist/1387.index.js +271 -0
- package/dist/1437.index.js +23 -0
- package/dist/1445.index.js +110 -0
- package/dist/1505.index.js +151 -0
- package/dist/1557.index.js +293 -0
- package/dist/1621.index.js +311 -0
- package/dist/1751.index.js +160 -0
- package/dist/1765.index.js +727 -0
- package/dist/1861.index.js +690 -0
- package/dist/1898.index.js +116 -0
- package/dist/1906.index.js +433 -0
- package/dist/1920.index.js +1 -291
- package/dist/208.index.js +1631 -0
- package/dist/2199.index.js +0 -239
- package/dist/2223.index.js +159 -0
- package/dist/2530.index.js +152 -0
- package/dist/2565.index.js +116 -0
- package/dist/2574.index.js +127 -0
- package/dist/2620.index.js +345 -0
- package/dist/2773.index.js +98 -0
- package/dist/2775.index.js +32 -0
- package/dist/2966.index.js +171 -0
- package/dist/2972.index.js +87 -0
- package/dist/2991.index.js +131 -0
- package/dist/3063.index.js +833 -0
- package/dist/3075.index.js +227 -0
- package/dist/3121.index.js +297 -0
- package/dist/3314.index.js +263 -0
- package/dist/3480.index.js +1822 -0
- package/dist/3481.index.js +24 -0
- package/dist/3506.index.js +268 -0
- package/dist/3533.index.js +591 -0
- package/dist/3653.index.js +450 -0
- package/dist/3822.index.js +131 -0
- package/dist/3868.index.js +292 -0
- package/dist/3917.index.js +152 -0
- package/dist/3918.index.js +192 -0
- package/dist/4090.index.js +288 -0
- package/dist/4121.index.js +1190 -0
- package/dist/4142.index.js +0 -239
- package/dist/4336.index.js +436 -0
- package/dist/4387.index.js +532 -0
- package/dist/4413.index.js +302 -0
- package/dist/4476.index.js +4510 -0
- package/dist/4491.index.js +133 -0
- package/dist/4538.index.js +185 -0
- package/dist/4684.index.js +319 -0
- package/dist/4740.index.js +165 -0
- package/dist/4780.index.js +209 -0
- package/dist/4802.index.js +225 -0
- package/dist/4812.index.js +379 -0
- package/dist/4835.index.js +126 -0
- package/dist/494.index.js +226 -0
- package/dist/4991.index.js +549 -0
- package/dist/5199.index.js +305 -0
- package/dist/5216.index.js +109 -0
- package/dist/5439.index.js +301 -0
- package/dist/5443.index.js +97 -0
- package/dist/5496.index.js +377 -0
- package/dist/550.index.js +2 -1
- package/dist/5801.index.js +588 -0
- package/dist/5858.index.js +223 -0
- package/dist/5933.index.js +143 -0
- package/dist/601.index.js +23 -0
- package/dist/6173.index.js +273 -0
- package/dist/6291.index.js +1871 -0
- package/dist/6312.index.js +666 -0
- package/dist/634.index.js +16182 -0
- package/dist/644.index.js +87 -0
- package/dist/6508.index.js +283 -0
- package/dist/6560.index.js +645 -0
- package/dist/6576.index.js +422 -0
- package/dist/6579.index.js +271 -0
- package/dist/6745.index.js +305 -0
- package/dist/6818.index.js +21642 -0
- package/dist/6972.index.js +167 -0
- package/dist/6995.index.js +163 -0
- package/dist/7249.index.js +191 -0
- package/dist/7372.index.js +1197 -0
- package/dist/7546.index.js +541 -0
- package/dist/76.index.js +448 -0
- package/dist/7879.index.js +163 -0
- package/dist/8025.index.js +130 -0
- package/dist/8054.index.js +248 -0
- package/dist/8097.index.js +201 -0
- package/dist/8126.index.js +191 -0
- package/dist/8178.index.js +78 -0
- package/dist/818.index.js +212 -0
- package/dist/8382.index.js +93 -0
- package/dist/8408.index.js +261 -0
- package/dist/8540.index.js +174 -0
- package/dist/8635.index.js +802 -0
- package/dist/8688.index.js +410 -0
- package/dist/8699.index.js +779 -0
- package/dist/8891.index.js +457 -0
- package/dist/8916.index.js +885 -0
- package/dist/9146.index.js +170 -0
- package/dist/9168.index.js +841 -0
- package/dist/9292.index.js +369 -0
- package/dist/9382.index.js +135 -11
- package/dist/9422.index.js +866 -0
- package/dist/9440.index.js +453 -0
- package/dist/9479.index.js +152 -0
- package/dist/9481.index.js +220 -0
- package/dist/9539.index.js +100 -0
- package/dist/9547.index.js +103 -0
- package/dist/9560.index.js +41 -61
- package/dist/9692.index.js +90 -0
- package/dist/9800.index.js +213 -1
- package/dist/9899.index.js +138 -0
- package/dist/9981.index.js +206 -0
- package/dist/999.index.js +117 -0
- package/dist/agentic-security.mjs +27 -27
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +31 -6
- package/src/context-intelligence/candidates.js +2 -1
- package/src/context-intelligence/capabilities.js +2 -1
- package/src/context-intelligence/cli.js +2 -0
- package/src/context-intelligence/config.js +3 -1
- package/src/context-intelligence/controls.js +49 -1
- package/src/context-intelligence/counterexample.js +200 -0
- package/src/context-intelligence/cross-route.js +111 -0
- package/src/context-intelligence/findings.js +166 -0
- package/src/context-intelligence/invariants.js +197 -0
- package/src/context-intelligence/queue.js +6 -1
- package/src/context-intelligence/reachability.js +2 -2
- package/src/context-intelligence/review.js +68 -4
- package/src/dataflow/access-paths.js +11 -0
- package/src/dataflow/analysis-limits.js +66 -0
- package/src/dataflow/engine.js +282 -24
- package/src/dataflow/service-edges.js +174 -0
- package/src/dataflow/summaries.js +7 -1
- package/src/discovery/judge.js +2 -1
- package/src/engine.js +82 -14
- package/src/fix/apply-fix-service.js +55 -8
- package/src/fix/batch-journal.js +84 -0
- package/src/fix/exploit-replay.js +51 -0
- package/src/fix/footprint.js +65 -0
- package/src/fix/patch-policy.js +49 -0
- package/src/fix/secret-persistence.js +39 -0
- package/src/fix/test-commands.js +84 -0
- package/src/fix/verification-adapters.js +97 -0
- package/src/fix/verification-record.js +243 -0
- package/src/fix/verify-patch.js +89 -0
- package/src/ir/CLAUDE.md +5 -0
- package/src/ir/callgraph.js +23 -1
- package/src/ir/module-resolver.js +315 -0
- package/src/ir/parser-js.js +21 -2
- package/src/ir/semantic-graph.js +245 -0
- package/src/language/haskell-findings.js +2 -0
- package/src/language/haskell-guards.js +1 -1
- package/src/language/haskell-ir.js +3 -1
- package/src/language/haskell-models.js +94 -0
- package/src/language/haskell-security-rules.js +20 -0
- package/src/language/nixos-hardening.js +72 -0
- package/src/lineage/build-attested-correlation.js +63 -0
- package/src/lineage/deployment-identity.js +76 -0
- package/src/mcp/tools.js +13 -34
- package/src/pipeline/canonical-output.js +64 -0
- package/src/pipeline/detector-runner.js +7 -0
- package/src/pipeline/scan-health.js +14 -0
- package/src/pipeline/supervised-analyzers.js +125 -0
- package/src/pipeline/supervised-child.js +17 -0
- package/src/pipeline/telemetry.js +79 -0
- package/src/posture/accuracy-scorecard.js +98 -9
- package/src/posture/artifact-registry.js +5 -0
- package/src/posture/assurance/evidence-registry.js +215 -0
- package/src/posture/assurance/release-package-verify.js +135 -0
- package/src/posture/assurance/release-package.js +118 -0
- package/src/posture/assurance/release-verdict.js +94 -0
- package/src/posture/assurance/waiver.js +200 -0
- package/src/posture/compliance-truth.js +100 -0
- package/src/posture/evaluation/scan-child.js +3 -1
- package/src/posture/evaluation/why-missed.js +3 -1
- package/src/posture/exploitability-evidence.js +141 -0
- package/src/posture/fix-verify.js +4 -2
- package/src/posture/fleet/exposure-ranking.js +115 -0
- package/src/posture/fleet/service-chain-graph.js +150 -0
- package/src/posture/graph-reconciliation.js +154 -0
- package/src/posture/provenance/coverage.js +103 -0
- package/src/posture/provenance/git-evidence.js +84 -0
- package/src/posture/provenance/origin-resolver.js +7 -1
- package/src/posture/provenance/predicate-replay.js +14 -4
- package/src/posture/runtime-correlation.js +61 -15
- package/src/posture/runtime-explanation.js +116 -0
- package/src/posture/runtime-trace-ingest.js +194 -0
- package/src/posture/scan-checkpoint.js +38 -10
- package/src/posture/waf-ingest.js +7 -2
- package/src/report/index.js +4 -0
- package/src/report/oscal.js +38 -0
- package/src/report/perspectives.js +150 -0
- package/src/sast/_comment-strip.js +8 -1
- package/src/sast/ruby.js +26 -0
- package/src/sast/zip-slip.js +79 -0
- package/src/util/string-memo.js +38 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// Import-aware module resolution (E2-02): which function does `svc.run(x)` in routes.js actually call?
|
|
2
|
+
//
|
|
3
|
+
// The call graph used to resolve a cross-file call by NAME alone: any function called `run` anywhere in the project could be the callee,
|
|
4
|
+
// and a member call through an imported module (`const svc = require('./service'); svc.run(x)`) was not resolved at all because `svc.run`
|
|
5
|
+
// is not a function name. That was a false negative for the second shape and a false-edge risk for the first. This resolver follows the
|
|
6
|
+
// program's own import/export statements instead.
|
|
7
|
+
//
|
|
8
|
+
// WHAT IT UNDERSTANDS (JavaScript and TypeScript, parsed with Babel; Python by line patterns)
|
|
9
|
+
// imports `import {a as b} from`, default, namespace, `import x = require()`, `const x = require()`, `const {a: b} = require()`,
|
|
10
|
+
// `const f = require('m').f`; Python `from m import a as b`, `import m as n`, `import a.b`
|
|
11
|
+
// exports `export function/const/class`, `export {a as b}`, `export {a} from`, `export * from`, `export default`, `exports.a =`,
|
|
12
|
+
// `module.exports.a =`, `module.exports = {a, b: fn}`, `module.exports = fn`, `module.exports = require('./x')`
|
|
13
|
+
// paths relative specifiers with extension and index-file probing, TypeScript's `./x.js` -> `./x.ts`, Python dotted modules and
|
|
14
|
+
// relative `from . import`
|
|
15
|
+
//
|
|
16
|
+
// WHAT IT DOES NOT, SAID OUT LOUD (each shows up as a status, never as a guess)
|
|
17
|
+
// dynamic `require(expr)` / `import()`, path aliases (`@/x`, `~/x`, tsconfig `paths`), conditional exports, `export * as ns`, call-through
|
|
18
|
+
// property chains deeper than one level (`a.b.c()`), an export that is not a function, shadowing of an imported name by a local.
|
|
19
|
+
//
|
|
20
|
+
// TERMINATION. Re-export chains are followed to a depth of MAX_HOPS with a visited set, so a cycle (a re-exports b re-exports a) ends as
|
|
21
|
+
// `cycle` and a long chain ends as `depth-limit`. The result always carries the hops it took.
|
|
22
|
+
import { parseSync } from '@babel/core';
|
|
23
|
+
import presetReact from '@babel/preset-react';
|
|
24
|
+
import presetTypescript from '@babel/preset-typescript';
|
|
25
|
+
|
|
26
|
+
export const MAX_HOPS = 8;
|
|
27
|
+
const MAX_FILE_BYTES = 1_500_000;
|
|
28
|
+
const BUILTINS = new Set(['assert', 'buffer', 'child_process', 'cluster', 'crypto', 'dgram', 'dns', 'events', 'fs', 'http', 'http2', 'https', 'net', 'os', 'path', 'perf_hooks', 'process', 'querystring', 'readline', 'stream', 'string_decoder', 'timers', 'tls', 'url', 'util', 'vm', 'worker_threads', 'zlib']);
|
|
29
|
+
const JS_EXT = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts'];
|
|
30
|
+
|
|
31
|
+
const posix = (p) => String(p).replace(/\\/g, '/');
|
|
32
|
+
function normalize(p) {
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const seg of posix(p).split('/')) { if (seg === '' || seg === '.') continue; if (seg === '..') { if (!out.length) return null; out.pop(); } else out.push(seg); }
|
|
35
|
+
return out.join('/');
|
|
36
|
+
}
|
|
37
|
+
const dirOf = (f) => { const i = posix(f).lastIndexOf('/'); return i < 0 ? '' : posix(f).slice(0, i); };
|
|
38
|
+
|
|
39
|
+
// ---- JavaScript / TypeScript extraction -------------------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
function parseJs(file, code) {
|
|
42
|
+
try {
|
|
43
|
+
return parseSync(code, { filename: file, presets: [presetReact, [presetTypescript, { ignoreExtensions: true }]], parserOpts: { plugins: ['decorators-legacy', 'decoratorAutoAccessors'] }, babelrc: false, configFile: false });
|
|
44
|
+
} catch { return null; }
|
|
45
|
+
}
|
|
46
|
+
const litString = (n) => (n && (n.type === 'StringLiteral' || (n.type === 'Literal' && typeof n.value === 'string')) ? n.value : (n && n.type === 'TemplateLiteral' && !n.expressions.length && n.quasis.length === 1 ? n.quasis[0].value.cooked : null));
|
|
47
|
+
const keyName = (k) => (k.type === 'Identifier' ? k.name : litString(k));
|
|
48
|
+
const isRequire = (n) => n && n.type === 'CallExpression' && n.callee.type === 'Identifier' && n.callee.name === 'require' && n.arguments.length === 1;
|
|
49
|
+
const isFnNode = (n) => n && (n.type === 'FunctionExpression' || n.type === 'ArrowFunctionExpression' || n.type === 'FunctionDeclaration');
|
|
50
|
+
|
|
51
|
+
export function extractJsFacts(file, code) {
|
|
52
|
+
const facts = { language: 'js', imports: [], exports: [], unsupported: [] };
|
|
53
|
+
const ast = parseJs(file, code);
|
|
54
|
+
if (!ast) { facts.unsupported.push('parse-failed'); return facts; }
|
|
55
|
+
const addImport = (local, kind, source, imported = null) => facts.imports.push({ local, kind, source, imported });
|
|
56
|
+
const addExport = (e) => facts.exports.push(e);
|
|
57
|
+
for (const st of ast.program.body) {
|
|
58
|
+
switch (st.type) {
|
|
59
|
+
case 'ImportDeclaration':
|
|
60
|
+
for (const sp of st.specifiers) {
|
|
61
|
+
if (sp.type === 'ImportSpecifier') addImport(sp.local.name, 'named', st.source.value, sp.imported.type === 'Identifier' ? sp.imported.name : sp.imported.value);
|
|
62
|
+
else if (sp.type === 'ImportDefaultSpecifier') addImport(sp.local.name, 'default', st.source.value);
|
|
63
|
+
else if (sp.type === 'ImportNamespaceSpecifier') addImport(sp.local.name, 'namespace', st.source.value);
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
case 'TSImportEqualsDeclaration':
|
|
67
|
+
if (st.moduleReference?.type === 'TSExternalModuleReference') addImport(st.id.name, 'namespace', litString(st.moduleReference.expression));
|
|
68
|
+
break;
|
|
69
|
+
case 'VariableDeclaration':
|
|
70
|
+
for (const d of st.declarations) {
|
|
71
|
+
const init = d.init;
|
|
72
|
+
if (isRequire(init)) {
|
|
73
|
+
const src = litString(init.arguments[0]);
|
|
74
|
+
if (src === null) { facts.unsupported.push('dynamic-require'); continue; }
|
|
75
|
+
if (d.id.type === 'Identifier') addImport(d.id.name, 'namespace', src);
|
|
76
|
+
else if (d.id.type === 'ObjectPattern') for (const p of d.id.properties) {
|
|
77
|
+
if (p.type !== 'ObjectProperty') { facts.unsupported.push('rest-in-require-pattern'); continue; }
|
|
78
|
+
const v = p.value.type === 'AssignmentPattern' ? p.value.left : p.value;
|
|
79
|
+
if (v.type === 'Identifier') addImport(v.name, 'named', src, keyName(p.key));
|
|
80
|
+
}
|
|
81
|
+
} else if (init && init.type === 'MemberExpression' && isRequire(init.object) && d.id.type === 'Identifier' && !init.computed) {
|
|
82
|
+
const src = litString(init.object.arguments[0]);
|
|
83
|
+
if (src !== null) addImport(d.id.name, 'named', src, init.property.name);
|
|
84
|
+
} else if (init && init.type === 'CallExpression' && init.callee.type === 'CallExpression' && isRequire(init.callee)) facts.unsupported.push('call-of-require-result');
|
|
85
|
+
if (isFnNode(init) && st.parent === undefined && d.id.type === 'Identifier') { /* local function value: found through the IR by name */ }
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
case 'ExportNamedDeclaration':
|
|
89
|
+
if (st.declaration) {
|
|
90
|
+
const dec = st.declaration;
|
|
91
|
+
if (dec.type === 'FunctionDeclaration' || dec.type === 'ClassDeclaration') addExport({ name: dec.id.name, local: dec.id.name, kind: dec.type === 'FunctionDeclaration' ? 'function' : 'value' });
|
|
92
|
+
else if (dec.type === 'VariableDeclaration') for (const d of dec.declarations) if (d.id.type === 'Identifier') addExport({ name: d.id.name, local: d.id.name, kind: isFnNode(d.init) ? 'function' : 'value' });
|
|
93
|
+
}
|
|
94
|
+
for (const sp of st.specifiers || []) {
|
|
95
|
+
if (sp.type !== 'ExportSpecifier') { facts.unsupported.push('export-namespace-specifier'); continue; }
|
|
96
|
+
const exported = sp.exported.type === 'Identifier' ? sp.exported.name : sp.exported.value;
|
|
97
|
+
if (st.source) addExport({ name: exported, kind: 'reexport', from: st.source.value, imported: sp.local.name });
|
|
98
|
+
else addExport({ name: exported, local: sp.local.name, kind: 'local' });
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
case 'ExportAllDeclaration':
|
|
102
|
+
addExport({ name: '*', kind: 'star', from: st.source.value });
|
|
103
|
+
break;
|
|
104
|
+
case 'ExportDefaultDeclaration': {
|
|
105
|
+
const d = st.declaration;
|
|
106
|
+
if ((d.type === 'FunctionDeclaration' || d.type === 'ClassDeclaration') && d.id) addExport({ name: 'default', local: d.id.name, kind: 'local' });
|
|
107
|
+
else if (d.type === 'Identifier') addExport({ name: 'default', local: d.name, kind: 'local' });
|
|
108
|
+
else if (isFnNode(d)) addExport({ name: 'default', local: null, kind: 'anonymous-function' });
|
|
109
|
+
else addExport({ name: 'default', local: null, kind: 'value' });
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case 'ExpressionStatement': {
|
|
113
|
+
const e = st.expression;
|
|
114
|
+
if (e.type === 'AssignmentExpression' && e.operator === '=' && e.left.type === 'MemberExpression') {
|
|
115
|
+
const L = e.left;
|
|
116
|
+
const objIsExports = L.object.type === 'Identifier' && L.object.name === 'exports';
|
|
117
|
+
const objIsModuleExports = L.object.type === 'MemberExpression' && L.object.object.type === 'Identifier' && L.object.object.name === 'module' && L.object.property.name === 'exports';
|
|
118
|
+
const isModuleExportsItself = L.object.type === 'Identifier' && L.object.name === 'module' && !L.computed && L.property.name === 'exports';
|
|
119
|
+
if ((objIsExports || objIsModuleExports) && !L.computed) {
|
|
120
|
+
const r = e.right;
|
|
121
|
+
addExport({ name: L.property.name, local: r.type === 'Identifier' ? r.name : null, kind: isFnNode(r) ? 'function' : r.type === 'Identifier' ? 'local' : 'value' });
|
|
122
|
+
} else if (isModuleExportsItself) {
|
|
123
|
+
const r = e.right;
|
|
124
|
+
if (r.type === 'ObjectExpression') for (const p of r.properties) {
|
|
125
|
+
if (p.type === 'ObjectProperty' || p.type === 'ObjectMethod') { const n = keyName(p.key); if (n) addExport({ name: n, local: p.type === 'ObjectProperty' && p.value.type === 'Identifier' ? p.value.name : null, kind: p.type === 'ObjectMethod' || (p.type === 'ObjectProperty' && isFnNode(p.value)) ? 'function' : p.type === 'ObjectProperty' && p.value.type === 'Identifier' ? 'local' : 'value' }); }
|
|
126
|
+
else facts.unsupported.push('spread-in-module-exports');
|
|
127
|
+
}
|
|
128
|
+
else if (isRequire(r)) { const src = litString(r.arguments[0]); if (src !== null) addExport({ name: '*', kind: 'star', from: src, whole: true }); else facts.unsupported.push('dynamic-require'); }
|
|
129
|
+
else if (r.type === 'Identifier') addExport({ name: 'default', local: r.name, kind: 'local' });
|
|
130
|
+
else if (isFnNode(r)) addExport({ name: 'default', local: r.id?.name ?? null, kind: r.id ? 'local' : 'anonymous-function' });
|
|
131
|
+
else addExport({ name: 'default', local: null, kind: 'value' });
|
|
132
|
+
}
|
|
133
|
+
} else if (e.type === 'CallExpression' && e.callee.type === 'Import') facts.unsupported.push('dynamic-import');
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
default: break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return facts;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---- Python extraction ------------------------------------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
export function extractPyFacts(file, code) {
|
|
145
|
+
const facts = { language: 'py', imports: [], exports: [], unsupported: [] };
|
|
146
|
+
for (const raw of String(code).split('\n')) {
|
|
147
|
+
const line = raw.replace(/#.*$/, '');
|
|
148
|
+
let m;
|
|
149
|
+
if ((m = /^from\s+(\.+)?([\w.]*)\s+import\s+(.+)$/.exec(line))) {
|
|
150
|
+
const source = `${m[1] || ''}${m[2] || ''}`;
|
|
151
|
+
const list = m[3].replace(/[()\\]/g, '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
152
|
+
for (const item of list) { const mm = /^(\w+|\*)(?:\s+as\s+(\w+))?$/.exec(item); if (!mm) continue; if (mm[1] === '*') facts.unsupported.push('star-import'); else facts.imports.push({ local: mm[2] || mm[1], kind: 'py-from', source, imported: mm[1] }); }
|
|
153
|
+
} else if ((m = /^import\s+(.+)$/.exec(line))) {
|
|
154
|
+
for (const item of m[1].split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
155
|
+
const mm = /^([\w.]+)(?:\s+as\s+(\w+))?$/.exec(item); if (!mm) continue;
|
|
156
|
+
facts.imports.push(mm[2] ? { local: mm[2], kind: 'py-module', source: mm[1] } : { local: mm[1].split('.')[0], kind: 'py-module-root', source: mm[1] });
|
|
157
|
+
}
|
|
158
|
+
} else if ((m = /^(?:async\s+)?def\s+(\w+)\s*\(/.exec(line))) facts.exports.push({ name: m[1], local: m[1], kind: 'function' });
|
|
159
|
+
}
|
|
160
|
+
return facts;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- index ------------------------------------------------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {Record<string,string>} fileContents repository-relative path -> source
|
|
167
|
+
* @param {Record<string,{functions:object[]}>} perFile the IR's perFile map
|
|
168
|
+
*/
|
|
169
|
+
export function buildModuleIndex(fileContents, perFile) {
|
|
170
|
+
const files = new Set(Object.keys(fileContents || {}).map(posix));
|
|
171
|
+
const facts = new Map();
|
|
172
|
+
for (const [f, code] of Object.entries(fileContents || {})) {
|
|
173
|
+
if (typeof code !== 'string' || code.length > MAX_FILE_BYTES) continue;
|
|
174
|
+
const file = posix(f);
|
|
175
|
+
if (/\.(js|jsx|ts|tsx|mjs|cjs|mts|cts)$/.test(file)) { if (/\b(import|export|require|module\.exports|exports\.)/.test(code)) facts.set(file, extractJsFacts(file, code)); }
|
|
176
|
+
else if (/\.py$/.test(file)) facts.set(file, extractPyFacts(file, code));
|
|
177
|
+
}
|
|
178
|
+
const fnsByFile = new Map();
|
|
179
|
+
for (const [f, ir] of Object.entries(perFile || {})) fnsByFile.set(posix(f), (ir?.functions || []).filter((fn) => fn.name !== '<module>'));
|
|
180
|
+
|
|
181
|
+
function resolveSpecifier(from, spec, language) {
|
|
182
|
+
if (typeof spec !== 'string' || !spec) return { kind: 'unresolved', reason: 'empty specifier' };
|
|
183
|
+
if (language === 'py') {
|
|
184
|
+
const rel = /^(\.+)(.*)$/.exec(spec);
|
|
185
|
+
let baseDir; let dotted;
|
|
186
|
+
if (rel) { baseDir = dirOf(from); for (let i = 1; i < rel[1].length; i++) baseDir = dirOf(baseDir); dotted = rel[2]; } else { baseDir = ''; dotted = spec; }
|
|
187
|
+
const rest = dotted ? dotted.split('.').join('/') : '';
|
|
188
|
+
const stem = normalize(baseDir ? (rest ? `${baseDir}/${rest}` : baseDir) : rest);
|
|
189
|
+
if (stem === null) return { kind: 'unresolved', reason: 'path leaves the repository' };
|
|
190
|
+
for (const c of [`${stem}.py`, `${stem}/__init__.py`]) if (stem !== '' && files.has(c)) return { kind: 'file', file: c };
|
|
191
|
+
if (stem === '' && files.has('__init__.py')) return { kind: 'file', file: '__init__.py' };
|
|
192
|
+
return rel ? { kind: 'unresolved', reason: `relative module ${spec} not found` } : { kind: 'external', reason: 'not a module of this repository' };
|
|
193
|
+
}
|
|
194
|
+
if (spec.startsWith('node:') || BUILTINS.has(spec.split('/')[0])) return { kind: 'external', reason: 'node builtin' };
|
|
195
|
+
if (spec.startsWith('.') || spec.startsWith('/')) {
|
|
196
|
+
const stem = normalize(spec.startsWith('/') ? spec.slice(1) : `${dirOf(from)}/${spec}`);
|
|
197
|
+
if (stem === null) return { kind: 'unresolved', reason: 'path leaves the repository' };
|
|
198
|
+
const noExt = stem.replace(/\.(m|c)?js$/, '');
|
|
199
|
+
const cands = [stem, ...JS_EXT.map((e) => stem + e), noExt + '.ts', noExt + '.tsx', noExt + '.mts', noExt + '.cts', ...JS_EXT.map((e) => `${stem}/index${e}`)];
|
|
200
|
+
for (const c of cands) if (files.has(c)) return { kind: 'file', file: c };
|
|
201
|
+
return { kind: 'unresolved', reason: `${spec} not found in the repository` };
|
|
202
|
+
}
|
|
203
|
+
if (/^(@\/|~\/|#|\$)/.test(spec)) return { kind: 'unresolved', reason: 'path alias: tsconfig paths and bundler aliases are not read' };
|
|
204
|
+
return { kind: 'external', reason: 'package import' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function functionIn(file, local) {
|
|
208
|
+
const hits = (fnsByFile.get(file) || []).filter((fn) => fn.name === local || String(fn.name).endsWith(`.${local}`));
|
|
209
|
+
if (hits.length === 1) return { qid: hits[0].qid };
|
|
210
|
+
return hits.length ? { ambiguous: hits.map((h) => h.qid) } : null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// follow an export of `file` named `name` to the function that implements it
|
|
214
|
+
function resolveExport(file, name, hops, visited) {
|
|
215
|
+
const key = `${file}#${name}`;
|
|
216
|
+
if (visited.has(key)) return { status: 'cycle', hops, reason: `re-export cycle at ${key}` };
|
|
217
|
+
if (hops.length >= MAX_HOPS) return { status: 'depth-limit', hops, reason: `more than ${MAX_HOPS} re-export hops` };
|
|
218
|
+
visited.add(key);
|
|
219
|
+
const f = facts.get(file);
|
|
220
|
+
if (!f) return { status: 'unresolved-export', hops, reason: `${file} has no import/export statements` };
|
|
221
|
+
const hop = (extra) => [...hops, { file, name, ...extra }];
|
|
222
|
+
const direct = f.exports.filter((e) => e.name === name && e.kind !== 'star');
|
|
223
|
+
if (direct.length) {
|
|
224
|
+
const e = direct[direct.length - 1];
|
|
225
|
+
if (e.kind === 'reexport') {
|
|
226
|
+
const spec = resolveSpecifier(file, e.from, f.language);
|
|
227
|
+
if (spec.kind === 'file') return resolveExport(spec.file, e.imported, hop({ via: 're-export', from: e.from }), visited);
|
|
228
|
+
return { status: spec.kind === 'external' ? 'external' : 'unresolved-import', hops: hop({ via: 're-export', from: e.from }), reason: spec.reason };
|
|
229
|
+
}
|
|
230
|
+
if (e.kind === 'anonymous-function') {
|
|
231
|
+
const cands = (fnsByFile.get(file) || []).filter((fn) => /^(default|anonymous|<anonymous>)/i.test(fn.name));
|
|
232
|
+
return cands.length === 1 ? { status: 'resolved', qid: cands[0].qid, file, exportName: name, hops: hop({}) } : { status: 'unresolved-export', hops: hop({}), reason: 'anonymous default export function not identifiable in the IR' };
|
|
233
|
+
}
|
|
234
|
+
if (e.kind === 'value') {
|
|
235
|
+
const byName = functionIn(file, name);
|
|
236
|
+
if (byName?.qid) return { status: 'resolved', qid: byName.qid, file, exportName: name, hops: hop({}) };
|
|
237
|
+
return { status: 'unresolved-export', hops: hop({}), reason: `${name} is exported but is not a function the IR knows` };
|
|
238
|
+
}
|
|
239
|
+
const local = e.local || name;
|
|
240
|
+
const fn = functionIn(file, local) || (local !== name ? functionIn(file, name) : null);
|
|
241
|
+
if (fn?.qid) return { status: 'resolved', qid: fn.qid, file, exportName: name, hops: hop({}) };
|
|
242
|
+
if (fn?.ambiguous) return { status: 'ambiguous', hops: hop({}), reason: `several functions named ${local} in ${file}`, candidates: fn.ambiguous };
|
|
243
|
+
// an imported binding re-exported by name (`import {a} from './x'; export {a}`)
|
|
244
|
+
const imp = f.imports.find((i) => i.local === local);
|
|
245
|
+
if (imp) return resolveBinding(file, imp, hop({ via: 'import-then-export' }), visited);
|
|
246
|
+
return { status: 'unresolved-export', hops: hop({}), reason: `${local} is exported by ${file} but no function of that name was found` };
|
|
247
|
+
}
|
|
248
|
+
// `export * from` / `module.exports = require()`: search each star target; more than one hit is ambiguous
|
|
249
|
+
const stars = f.exports.filter((e) => e.kind === 'star');
|
|
250
|
+
const found = [];
|
|
251
|
+
for (const s of stars) {
|
|
252
|
+
const spec = resolveSpecifier(file, s.from, f.language);
|
|
253
|
+
if (spec.kind !== 'file') continue;
|
|
254
|
+
const r = resolveExport(spec.file, name, hop({ via: s.whole ? 'module.exports = require' : 'export *', from: s.from }), new Set(visited));
|
|
255
|
+
if (r.status === 'resolved') found.push(r); else if (r.status === 'cycle' || r.status === 'depth-limit') return r;
|
|
256
|
+
}
|
|
257
|
+
if (found.length === 1) return found[0];
|
|
258
|
+
if (found.length > 1) return { status: 'ambiguous', hops: hop({}), reason: `${name} is provided by ${found.length} star re-exports`, candidates: found.map((r) => r.qid) };
|
|
259
|
+
return { status: 'unresolved-export', hops: hop({}), reason: `${file} does not export ${name}` };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function resolveBinding(callerFile, imp, hops, visited) {
|
|
263
|
+
const f = facts.get(callerFile);
|
|
264
|
+
const spec = resolveSpecifier(callerFile, imp.source, f?.language);
|
|
265
|
+
if (spec.kind === 'external') return { status: 'external', hops, reason: spec.reason, source: imp.source };
|
|
266
|
+
if (spec.kind === 'unresolved') return { status: 'unresolved-import', hops, reason: spec.reason, source: imp.source };
|
|
267
|
+
const name = imp.kind === 'default' ? 'default' : imp.kind === 'py-from' ? imp.imported : imp.imported;
|
|
268
|
+
if (name == null) return { status: 'module', file: spec.file, hops, source: imp.source };
|
|
269
|
+
return resolveExport(spec.file, name, [...hops, { file: callerFile, via: `import ${imp.source}` }], visited);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Resolve the callee text of a call made in `callerFile`. Never throws; always terminates. */
|
|
273
|
+
function resolveCall(callerFile, calleeText) {
|
|
274
|
+
const caller = posix(callerFile);
|
|
275
|
+
const f = facts.get(caller);
|
|
276
|
+
if (!f || typeof calleeText !== 'string' || !calleeText) return { status: 'no-facts', hops: [] };
|
|
277
|
+
const parts = calleeText.split('.');
|
|
278
|
+
const head = parts[0];
|
|
279
|
+
const imp = f.imports.find((i) => i.local === head);
|
|
280
|
+
if (!imp) return { status: 'not-imported', hops: [] };
|
|
281
|
+
const visited = new Set();
|
|
282
|
+
if (parts.length === 1) {
|
|
283
|
+
if (imp.kind === 'namespace' || imp.kind === 'py-module' || imp.kind === 'py-module-root') {
|
|
284
|
+
// calling the module object itself: `const f = require('./f'); f(x)` -> the module's default / module.exports function
|
|
285
|
+
const spec = resolveSpecifier(caller, imp.source, f.language);
|
|
286
|
+
if (spec.kind !== 'file') return { status: spec.kind === 'external' ? 'external' : 'unresolved-import', hops: [], reason: spec.reason, source: imp.source };
|
|
287
|
+
return resolveExport(spec.file, 'default', [{ file: caller, via: `import ${imp.source}` }], visited);
|
|
288
|
+
}
|
|
289
|
+
return resolveBinding(caller, imp, [], visited);
|
|
290
|
+
}
|
|
291
|
+
if (parts.length > 2 && !(imp.kind === 'py-module-root')) return { status: 'unsupported', hops: [], reason: `${calleeText}: property chains deeper than one level are not resolved`, source: imp.source };
|
|
292
|
+
if (imp.kind === 'namespace' || imp.kind === 'py-module') {
|
|
293
|
+
const spec = resolveSpecifier(caller, imp.source, f.language);
|
|
294
|
+
if (spec.kind === 'external') return { status: 'external', hops: [], reason: spec.reason, source: imp.source };
|
|
295
|
+
if (spec.kind !== 'file') return { status: 'unresolved-import', hops: [], reason: spec.reason, source: imp.source };
|
|
296
|
+
return resolveExport(spec.file, parts[1], [{ file: caller, via: `import ${imp.source}` }], visited);
|
|
297
|
+
}
|
|
298
|
+
if (imp.kind === 'py-module-root') {
|
|
299
|
+
// `import a.b` binds `a`; `a.b.fn()` names the module a/b and the function fn
|
|
300
|
+
const dotted = parts.slice(0, -1).join('.');
|
|
301
|
+
if (!imp.source.startsWith(dotted)) return { status: 'unsupported', hops: [], reason: `${calleeText} does not match import ${imp.source}`, source: imp.source };
|
|
302
|
+
const spec = resolveSpecifier(caller, dotted, 'py');
|
|
303
|
+
if (spec.kind === 'external') return { status: 'external', hops: [], reason: spec.reason, source: imp.source };
|
|
304
|
+
if (spec.kind !== 'file') return { status: 'unresolved-import', hops: [], reason: spec.reason, source: imp.source };
|
|
305
|
+
return resolveExport(spec.file, parts[parts.length - 1], [{ file: caller, via: `import ${imp.source}` }], visited);
|
|
306
|
+
}
|
|
307
|
+
// `x.y()` where x is a named/default import: a method on an imported value, not a module member. If the import is an external
|
|
308
|
+
// package that is still the answer that matters (`axios.get` is the package's, not ours).
|
|
309
|
+
const specOther = resolveSpecifier(caller, imp.source, f.language);
|
|
310
|
+
if (specOther.kind === 'external') return { status: 'external', hops: [], reason: specOther.reason, source: imp.source };
|
|
311
|
+
return { status: 'unsupported', hops: [], reason: `${calleeText}: member of an imported value, not of a module`, source: imp.source };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return { facts, resolveSpecifier, resolveExport: (file, name) => resolveExport(posix(file), name, [], new Set()), resolveCall };
|
|
315
|
+
}
|
package/src/ir/parser-js.js
CHANGED
|
@@ -182,7 +182,13 @@ function exprOf(n) {
|
|
|
182
182
|
return { key: resolveObjectKey(p), value: exprOf(p.value) };
|
|
183
183
|
}).filter(Boolean),
|
|
184
184
|
};
|
|
185
|
-
case 'ArrayExpression':
|
|
185
|
+
case 'ArrayExpression': {
|
|
186
|
+
// `spreads` is additive and present only when an element is `...x`: the position of every later element is then unknown, which the
|
|
187
|
+
// engine needs in order to treat the literal index by index (E2-03). Consumers that ignore it see the same shape as before.
|
|
188
|
+
const spreads = [];
|
|
189
|
+
(n.elements || []).forEach((el, i) => { if (el && el.type === 'SpreadElement') spreads.push(i); });
|
|
190
|
+
return spreads.length ? { kind: 'array', elements: (n.elements || []).map(exprOf), spreads } : { kind: 'array', elements: (n.elements || []).map(exprOf) };
|
|
191
|
+
}
|
|
186
192
|
case 'SpreadElement': return exprOf(n.argument);
|
|
187
193
|
case 'ThisExpression': return { kind: 'ident', name: '_this_' };
|
|
188
194
|
// Taint-recall PRD (80%) Tier 3: JSX had ZERO IR modeling at all — every
|
|
@@ -486,7 +492,20 @@ export function parseJsFile(file, code) {
|
|
|
486
492
|
const parentName = stack[stack.length - 1]?.name || '';
|
|
487
493
|
enterFn(name || 'anon', parentName, path.node, path.node.params || []);
|
|
488
494
|
},
|
|
489
|
-
exit() {
|
|
495
|
+
exit(path) {
|
|
496
|
+
// An arrow with an EXPRESSION body (`(req) => req.query.c`) returns that expression. It has no ReturnStatement, so the function used to
|
|
497
|
+
// record no return at all and every helper written this way was invisible to return-taint summaries. The return node is added on exit so
|
|
498
|
+
// it follows any call nodes the expression contained.
|
|
499
|
+
const fn = currentFn();
|
|
500
|
+
const body = path.node.body;
|
|
501
|
+
if (fn && body && body.type !== 'BlockStatement') {
|
|
502
|
+
const nodeId = nextNodeId();
|
|
503
|
+
addNode(fn, { id: nodeId, kind: 'return', value: exprOf(body), line: body.loc?.start?.line || path.node.loc?.start?.line || 0, succ: [], pred: [] });
|
|
504
|
+
fn.returns.push(nodeId);
|
|
505
|
+
linkCfg(fn, nodeId, fn.cfg.exit);
|
|
506
|
+
}
|
|
507
|
+
exitFn();
|
|
508
|
+
},
|
|
490
509
|
},
|
|
491
510
|
ClassMethod: {
|
|
492
511
|
enter(path) {
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// SemanticGraph v1: a typed, versioned, additive view over the Layer-1 IR (E2-01).
|
|
2
|
+
//
|
|
3
|
+
// WHY. The IR (`buildProjectIR`) is a bag of per-function records consumed by the taint engine. It has no stable identifiers, no shared
|
|
4
|
+
// entity types and no serialization contract, so every analysis that needs "the call at this place" or "where did this value come from"
|
|
5
|
+
// re-derives it with its own conventions, and nothing can be compared across runs. This module names the entities once, gives each a
|
|
6
|
+
// STABLE id, and fixes a versioned wire format.
|
|
7
|
+
//
|
|
8
|
+
// ADDITIVE. Building the graph reads the IR and never modifies it; it is not wired into findings, `chain[]` or last-scan.json. Existing
|
|
9
|
+
// output is unchanged by construction (a test pins that). A consumer opts in by calling `buildSemanticGraph`.
|
|
10
|
+
//
|
|
11
|
+
// HONEST. An entity type the IR cannot yet supply (Sanitizer, Guard, ServiceBoundary, StorageEdge) is declared in the schema, emitted as an
|
|
12
|
+
// empty list, and reported `schema-only` in `coverage` with the reason. A location the IR does not carry (column) is `null` and the entity
|
|
13
|
+
// says `locationPrecision: 'line'`. Nothing is guessed to fill a field.
|
|
14
|
+
//
|
|
15
|
+
// STABLE IDS. An id is the first 16 hex of SHA-256 over the entity's kind and its identity fields, which never include a line number: adding
|
|
16
|
+
// a blank line above a function does not change any id in it. Ties between otherwise identical entities (two `exec(x)` in one function) are
|
|
17
|
+
// broken by their order of appearance in that function.
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { analysisLimits } from '../dataflow/analysis-limits.js';
|
|
20
|
+
|
|
21
|
+
export const SEMANTIC_GRAPH_SCHEMA = 'agentic-security/semantic-graph';
|
|
22
|
+
export const SEMANTIC_GRAPH_VERSION = '1.0.0';
|
|
23
|
+
export const SUPPORTED_MAJOR = 1;
|
|
24
|
+
|
|
25
|
+
export const ENTITY_LISTS = Object.freeze(['symbols', 'callSites', 'allocations', 'fieldAccesses', 'valueOrigins', 'serviceBoundaries', 'storageEdges', 'sanitizers', 'guards']);
|
|
26
|
+
export const CONFIDENCE_TIERS = Object.freeze(['observed', 'inferred', 'unresolved']);
|
|
27
|
+
const SYMBOL_KINDS = Object.freeze(['module', 'function', 'class', 'variable', 'parameter']);
|
|
28
|
+
const ORIGIN_KINDS = Object.freeze(['parameter', 'call-result', 'literal', 'member', 'concat', 'object', 'array', 'function', 'identifier', 'unknown']);
|
|
29
|
+
|
|
30
|
+
const ID_PREFIX = Object.freeze({ symbols: 'sym', callSites: 'call', allocations: 'alloc', fieldAccesses: 'field', valueOrigins: 'orig', serviceBoundaries: 'svc', storageEdges: 'store', sanitizers: 'san', guards: 'guard' });
|
|
31
|
+
|
|
32
|
+
const h16 = (parts) => createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16);
|
|
33
|
+
const stripLine = (qid) => String(qid).replace(/@\d+$/, '');
|
|
34
|
+
|
|
35
|
+
// ---- canonical serialization ------------------------------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
function canon(v) {
|
|
38
|
+
if (Array.isArray(v)) return v.map(canon);
|
|
39
|
+
if (v && typeof v === 'object') return Object.fromEntries(Object.keys(v).sort().map((k) => [k, canon(v[k])]));
|
|
40
|
+
return v;
|
|
41
|
+
}
|
|
42
|
+
export function serializeGraph(graph) {
|
|
43
|
+
const g = { ...graph };
|
|
44
|
+
for (const k of ENTITY_LISTS) g[k] = [...(graph[k] || [])].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
45
|
+
return JSON.stringify(canon(g));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- validation -------------------------------------------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/** @returns {{ok:boolean, errors:{code:string,path:string,message:string}[]}} Never throws on hostile input. */
|
|
51
|
+
export function validateGraph(g) {
|
|
52
|
+
const errors = [];
|
|
53
|
+
const err = (code, path, message) => errors.push({ code, path, message });
|
|
54
|
+
if (!g || typeof g !== 'object' || Array.isArray(g)) return { ok: false, errors: [{ code: 'NOT_AN_OBJECT', path: '', message: 'a graph is an object' }] };
|
|
55
|
+
if (g.schema !== SEMANTIC_GRAPH_SCHEMA) { err('SCHEMA_MISMATCH', 'schema', `expected ${SEMANTIC_GRAPH_SCHEMA}`); return { ok: false, errors }; }
|
|
56
|
+
const m = typeof g.schemaVersion === 'string' ? /^(\d+)\.(\d+)\.(\d+)$/.exec(g.schemaVersion) : null;
|
|
57
|
+
if (!m) { err('BAD_VERSION', 'schemaVersion', 'must be a semantic version'); return { ok: false, errors }; }
|
|
58
|
+
if (Number(m[1]) !== SUPPORTED_MAJOR) { err('UNSUPPORTED_MAJOR', 'schemaVersion', `major ${m[1]} is not supported (supported: ${SUPPORTED_MAJOR}); migrate it first`); return { ok: false, errors }; }
|
|
59
|
+
const ids = new Map();
|
|
60
|
+
for (const list of ENTITY_LISTS) {
|
|
61
|
+
if (!Array.isArray(g[list])) { err('MISSING_FIELD', list, 'must be a list (empty when none)'); continue; }
|
|
62
|
+
g[list].forEach((e, i) => {
|
|
63
|
+
const p = `${list}[${i}]`;
|
|
64
|
+
if (!e || typeof e !== 'object' || Array.isArray(e)) { err('BAD_TYPE', p, 'must be an object'); return; }
|
|
65
|
+
if (typeof e.id !== 'string' || !new RegExp(`^${ID_PREFIX[list]}:[0-9a-f]{16}$`).test(e.id)) err('BAD_ID', `${p}.id`, `must look like ${ID_PREFIX[list]}:<16 hex>`);
|
|
66
|
+
else if (ids.has(e.id)) err('DUPLICATE_ID', `${p}.id`, `duplicate id ${e.id}`); else ids.set(e.id, list);
|
|
67
|
+
if (typeof e.file !== 'string' || !e.file || e.file.startsWith('/') || e.file.split('/').includes('..')) err('BAD_TYPE', `${p}.file`, 'must be a repository-relative path');
|
|
68
|
+
if (!(Number.isInteger(e.line) && e.line >= 0)) err('BAD_TYPE', `${p}.line`, 'line must be a non-negative integer');
|
|
69
|
+
if (!(e.column === null || (Number.isInteger(e.column) && e.column >= 0))) err('BAD_TYPE', `${p}.column`, 'column must be null or a non-negative integer');
|
|
70
|
+
if (e.column === null && e.locationPrecision !== 'line') err('RULE_VIOLATION', `${p}.locationPrecision`, "a null column requires locationPrecision 'line'");
|
|
71
|
+
const c = e.confidence;
|
|
72
|
+
if (!c || typeof c !== 'object' || !CONFIDENCE_TIERS.includes(c.tier) || typeof c.basis !== 'string' || !c.basis) err('BAD_TYPE', `${p}.confidence`, 'needs { tier, basis }');
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (errors.length) return { ok: false, errors };
|
|
76
|
+
const need = (e, p, field, list) => { if (e[field] !== null && e[field] !== undefined && ids.get(e[field]) !== list) err('DANGLING_REF', `${p}.${field}`, `${e[field]} is not a ${list} id`); };
|
|
77
|
+
g.symbols.forEach((e, i) => { if (!SYMBOL_KINDS.includes(e.kind)) err('UNKNOWN_ENUM', `symbols[${i}].kind`, `unknown symbol kind ${e.kind}`); need(e, `symbols[${i}]`, 'parent', 'symbols'); });
|
|
78
|
+
g.callSites.forEach((e, i) => { need(e, `callSites[${i}]`, 'caller', 'symbols'); need(e, `callSites[${i}]`, 'callee', 'symbols'); if (e.callee && e.resolution !== 'resolved') err('RULE_VIOLATION', `callSites[${i}].resolution`, 'a call with a callee must be resolved'); if (!e.callee && e.resolution === 'resolved') err('RULE_VIOLATION', `callSites[${i}].resolution`, 'a resolved call needs a callee'); (e.args || []).forEach((a, j) => need(a, `callSites[${i}].args[${j}]`, 'origin', 'valueOrigins')); });
|
|
79
|
+
g.valueOrigins.forEach((e, i) => { if (!ORIGIN_KINDS.includes(e.kind)) err('UNKNOWN_ENUM', `valueOrigins[${i}].kind`, `unknown origin kind ${e.kind}`); need(e, `valueOrigins[${i}]`, 'symbol', 'symbols'); });
|
|
80
|
+
g.fieldAccesses.forEach((e, i) => { need(e, `fieldAccesses[${i}]`, 'symbol', 'symbols'); if (!['read', 'write'].includes(e.mode)) err('UNKNOWN_ENUM', `fieldAccesses[${i}].mode`, 'mode must be read or write'); });
|
|
81
|
+
g.allocations.forEach((e, i) => need(e, `allocations[${i}]`, 'symbol', 'symbols'));
|
|
82
|
+
if (!g.coverage || typeof g.coverage !== 'object') err('MISSING_FIELD', 'coverage', 'coverage is required');
|
|
83
|
+
else for (const list of ENTITY_LISTS) { const c = g.coverage[list]; if (!c || !['populated', 'schema-only'].includes(c.state) || !c.reason) err('BAD_TYPE', `coverage.${list}`, 'needs { state, reason }'); else if (c.state === 'schema-only' && g[list].length) err('RULE_VIOLATION', `coverage.${list}`, 'schema-only but not empty'); else if (c.state === 'populated' && !g[list].length) err('RULE_VIOLATION', `coverage.${list}`, 'populated but empty'); }
|
|
84
|
+
return { ok: errors.length === 0, errors };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Parse and validate a serialized graph. Returns { ok, graph?, errors }. */
|
|
88
|
+
export function parseGraph(text) {
|
|
89
|
+
let g; try { g = JSON.parse(text); } catch (e) { return { ok: false, errors: [{ code: 'BAD_JSON', path: '', message: e.message }] }; }
|
|
90
|
+
const v = validateGraph(g);
|
|
91
|
+
return v.ok ? { ok: true, graph: g, errors: [] } : { ok: false, errors: v.errors };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Bring an older-minor graph to this version. A different major is refused: there is no silent best-effort. */
|
|
95
|
+
export function migrateGraph(g) {
|
|
96
|
+
const m = typeof g?.schemaVersion === 'string' ? /^(\d+)\.(\d+)\.(\d+)$/.exec(g.schemaVersion) : null;
|
|
97
|
+
if (!m || g.schema !== SEMANTIC_GRAPH_SCHEMA) return { ok: false, reason: 'not a semantic graph' };
|
|
98
|
+
if (Number(m[1]) !== SUPPORTED_MAJOR) return { ok: false, reason: `major ${m[1]} has no migration path to ${SEMANTIC_GRAPH_VERSION}` };
|
|
99
|
+
return { ok: true, graph: { ...g, schemaVersion: SEMANTIC_GRAPH_VERSION }, migrated: g.schemaVersion !== SEMANTIC_GRAPH_VERSION };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- builder ----------------------------------------------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
const exprText = (e) => {
|
|
105
|
+
if (!e || typeof e !== 'object') return '';
|
|
106
|
+
switch (e.kind) {
|
|
107
|
+
case 'ident': return e.name;
|
|
108
|
+
case 'member': return `${exprText(e.object)}.${e.prop ?? '[]'}`;
|
|
109
|
+
case 'call': return `${exprText(e.callee)}(...)`;
|
|
110
|
+
case 'literal': return JSON.stringify(e.value);
|
|
111
|
+
default: return `<${e.kind || 'unknown'}>`;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const originKindOf = (e, params) => {
|
|
115
|
+
if (!e) return 'unknown';
|
|
116
|
+
if (e.kind === 'ident') return params.includes(e.name) ? 'parameter' : 'identifier';
|
|
117
|
+
if (e.kind === 'call') return 'call-result';
|
|
118
|
+
if (e.kind === 'literal') return 'literal';
|
|
119
|
+
if (e.kind === 'member') return 'member';
|
|
120
|
+
if (e.kind === 'binary' || e.kind === 'tpl') return 'concat';
|
|
121
|
+
if (e.kind === 'object') return 'object';
|
|
122
|
+
if (e.kind === 'array') return 'array';
|
|
123
|
+
if (e.kind === 'function-value') return 'function';
|
|
124
|
+
return 'unknown';
|
|
125
|
+
};
|
|
126
|
+
const loc = (file, line) => ({ file, line: Number.isInteger(line) ? line : 0, column: null, locationPrecision: 'line' });
|
|
127
|
+
const OBSERVED = { tier: 'observed', basis: 'read directly from the Layer-1 IR' };
|
|
128
|
+
const UNRESOLVED = { tier: 'unresolved', basis: 'the IR holds no resolved target for this call' };
|
|
129
|
+
|
|
130
|
+
function memberChain(e) {
|
|
131
|
+
const path = [];
|
|
132
|
+
let cur = e;
|
|
133
|
+
while (cur && cur.kind === 'member') { path.unshift(cur.prop ?? '[]'); cur = cur.object; }
|
|
134
|
+
return cur && cur.kind === 'ident' ? { base: cur.name, path } : null;
|
|
135
|
+
}
|
|
136
|
+
function walkExprs(e, visit) {
|
|
137
|
+
if (!e || typeof e !== 'object') return;
|
|
138
|
+
visit(e);
|
|
139
|
+
for (const k of ['object', 'callee', 'left', 'right', 'test', 'cons', 'alt']) if (e[k]) walkExprs(e[k], visit);
|
|
140
|
+
for (const k of ['args', 'parts', 'elements', 'values']) if (Array.isArray(e[k])) e[k].forEach((x) => walkExprs(x, visit));
|
|
141
|
+
if (Array.isArray(e.props)) e.props.forEach((p) => walkExprs(p?.value ?? p, visit));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {{perFile:object, callGraph?:object}} projectIR the object `buildProjectIR` returns
|
|
146
|
+
* @param {{engineVersion?:string}} [meta]
|
|
147
|
+
*/
|
|
148
|
+
export function buildSemanticGraph(projectIR, meta = {}) {
|
|
149
|
+
const g = Object.fromEntries(ENTITY_LISTS.map((k) => [k, []]));
|
|
150
|
+
const ordinal = new Map();
|
|
151
|
+
const next = (key) => { const n = ordinal.get(key) ?? 0; ordinal.set(key, n + 1); return n; };
|
|
152
|
+
const symByQid = new Map();
|
|
153
|
+
const files = Object.keys(projectIR?.perFile || {}).sort();
|
|
154
|
+
|
|
155
|
+
// pass 1: symbols
|
|
156
|
+
for (const file of files) {
|
|
157
|
+
const pf = projectIR.perFile[file];
|
|
158
|
+
for (const fn of pf.functions || []) {
|
|
159
|
+
const isModule = fn.name === '<module>';
|
|
160
|
+
const key = `${file}|${stripLine(fn.qid)}`;
|
|
161
|
+
const id = `sym:${h16(['symbol', file, stripLine(fn.qid), next(`sym|${key}`)])}`;
|
|
162
|
+
symByQid.set(fn.qid, id);
|
|
163
|
+
g.symbols.push({ id, kind: isModule ? 'module' : 'function', name: fn.name, qid: stripLine(fn.qid), parent: null, params: [...(fn.params || [])], ...loc(file, fn.line), confidence: OBSERVED });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// function-by-name index for DIRECT same-file resolution only; anything else stays unresolved (cross-file resolution is E2-02)
|
|
167
|
+
const byFileName = new Map();
|
|
168
|
+
for (const file of files) for (const fn of projectIR.perFile[file].functions || []) if (fn.name !== '<module>') { const k = `${file}|${fn.name}`; byFileName.set(k, byFileName.has(k) ? null : symByQid.get(fn.qid)); }
|
|
169
|
+
|
|
170
|
+
// pass 2: call sites, origins, field accesses, allocations
|
|
171
|
+
for (const file of files) {
|
|
172
|
+
for (const fn of projectIR.perFile[file].functions || []) {
|
|
173
|
+
const symId = symByQid.get(fn.qid);
|
|
174
|
+
const params = fn.params || [];
|
|
175
|
+
const originFor = (expr, variable, line) => {
|
|
176
|
+
const kind = originKindOf(expr, params);
|
|
177
|
+
const o = { id: `orig:${h16(['origin', symId, variable, kind, exprText(expr), next(`orig|${symId}|${variable}|${kind}|${exprText(expr)}`)])}`, symbol: symId, variable, kind, text: exprText(expr), ...loc(file, line), confidence: kind === 'unknown' ? { tier: 'unresolved', basis: 'the IR could not model this expression' } : OBSERVED };
|
|
178
|
+
g.valueOrigins.push(o);
|
|
179
|
+
return o.id;
|
|
180
|
+
};
|
|
181
|
+
const field = (expr, mode, line) => {
|
|
182
|
+
const mc = memberChain(expr);
|
|
183
|
+
if (!mc || !mc.path.length) return;
|
|
184
|
+
g.fieldAccesses.push({ id: `field:${h16(['field', symId, mc.base, mc.path, mode, next(`field|${symId}|${mc.base}|${mc.path.join('.')}|${mode}`)])}`, symbol: symId, base: mc.base, path: mc.path, mode, ...loc(file, line), confidence: OBSERVED });
|
|
185
|
+
};
|
|
186
|
+
const alloc = (expr, line) => {
|
|
187
|
+
const kind = expr.kind === 'object' ? 'object' : expr.kind === 'array' ? 'array' : expr.kind === 'function-value' ? 'function' : null;
|
|
188
|
+
if (!kind) return;
|
|
189
|
+
g.allocations.push({ id: `alloc:${h16(['alloc', symId, kind, next(`alloc|${symId}|${kind}`)])}`, symbol: symId, kind, ...loc(file, line), confidence: OBSERVED });
|
|
190
|
+
};
|
|
191
|
+
for (const c of fn.calls || []) {
|
|
192
|
+
const calleeText = c.callee;
|
|
193
|
+
const direct = byFileName.get(`${file}|${calleeText}`) || null;
|
|
194
|
+
const args = (c.args || []).map((a, index) => { walkExprs(a, (x) => { if (x.kind === 'member') field(x, 'read', c.line); alloc(x, c.line); }); return { index, origin: originFor(a, `arg:${calleeText}:${index}`, c.line) }; });
|
|
195
|
+
g.callSites.push({ id: `call:${h16(['call', symId, calleeText, next(`call|${symId}|${calleeText}`)])}`, caller: symId, calleeText, callee: direct, resolution: direct ? 'resolved' : 'unresolved', args, ...loc(file, c.line), confidence: direct ? { tier: 'inferred', basis: 'a function of this name exists in the same file' } : UNRESOLVED });
|
|
196
|
+
}
|
|
197
|
+
const callFromExpr = (x, line) => {
|
|
198
|
+
const calleeText = exprText(x.callee).replace(/\(\.\.\.\)$/, '');
|
|
199
|
+
const direct = x.callee?.kind === 'ident' ? (byFileName.get(`${file}|${calleeText}`) || null) : null;
|
|
200
|
+
const args = (x.args || []).map((a, index) => ({ index, origin: originFor(a, `arg:${calleeText}:${index}`, line) }));
|
|
201
|
+
g.callSites.push({ id: `call:${h16(['call', symId, calleeText, next(`call|${symId}|${calleeText}`)])}`, caller: symId, calleeText, callee: direct, resolution: direct ? 'resolved' : 'unresolved', args, ...loc(file, line), confidence: direct ? { tier: 'inferred', basis: 'a function of this name exists in the same file' } : UNRESOLVED });
|
|
202
|
+
};
|
|
203
|
+
// The CFG is the one structure every language's IR shares (`writes` exists for JavaScript only): assignment nodes carry { target, source, line }.
|
|
204
|
+
const assigns = Object.values(fn.cfg?.nodes || {}).filter((n) => n.kind === 'assign' && n.target).sort((x, y) => (x.line ?? 0) - (y.line ?? 0) || String(x.id ?? '').localeCompare(String(y.id ?? '')));
|
|
205
|
+
for (const d of assigns) {
|
|
206
|
+
const variable = String(d.target);
|
|
207
|
+
const line = d.line ?? fn.line;
|
|
208
|
+
originFor(d.source, variable, line);
|
|
209
|
+
// calls that appear INSIDE an assignment source (`const cmd = h.build(x)`) are not in fn.calls; they are call sites all the same
|
|
210
|
+
walkExprs(d.source, (x) => { if (x.kind === 'member') field(x, 'read', line); alloc(x, line); if (x.kind === 'call' && x.callee) callFromExpr(x, line); });
|
|
211
|
+
const mc = variable.includes('.') ? variable.split('.') : null;
|
|
212
|
+
if (mc) g.fieldAccesses.push({ id: `field:${h16(['field', symId, mc[0], mc.slice(1), 'write', next(`field|${symId}|${mc[0]}|${mc.slice(1).join('.')}|write`)])}`, symbol: symId, base: mc[0], path: mc.slice(1), mode: 'write', ...loc(file, line), confidence: OBSERVED });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// E2-07: service relationships are opt-in. A caller that ran dataflow/service-edges.js passes the result as `meta.serviceEdges`; HTTP and
|
|
218
|
+
// messaging relationships become `serviceBoundaries`, persistence relationships become `storageEdges`, and relationships with no counterpart
|
|
219
|
+
// are kept as boundaries with `to: null` and unresolved confidence. Without it both lists stay schema-only, as before.
|
|
220
|
+
const se = meta.serviceEdges && meta.serviceEdges.optedIn ? meta.serviceEdges : null;
|
|
221
|
+
if (se) {
|
|
222
|
+
const tier = (e) => (e === 'observed' ? 'observed' : e === 'inferred' ? 'inferred' : 'unresolved');
|
|
223
|
+
for (const e of se.edges) {
|
|
224
|
+
const list = e.kind === 'persistence' ? g.storageEdges : g.serviceBoundaries;
|
|
225
|
+
list.push({ id: `${e.kind === 'persistence' ? 'store' : 'svc'}:${e.id.slice('svcedge:'.length)}`, kind: e.kind, from: e.from, to: e.to, via: e.via, ...loc(e.from.file, e.from.line), evidence: e.evidence, confidence: { tier: tier(e.evidence), basis: e.basis } });
|
|
226
|
+
}
|
|
227
|
+
for (const u of se.unknown) g.serviceBoundaries.push({ id: `svc:${h16(['unknown', u.service, u.file, u.kind, u.reason])}`, kind: u.kind, from: { service: u.service, file: u.file, line: u.line }, to: null, via: null, ...loc(u.file, u.line), evidence: 'unknown', confidence: { tier: 'unresolved', basis: u.reason } });
|
|
228
|
+
}
|
|
229
|
+
const SCHEMA_ONLY = {
|
|
230
|
+
...(se ? {} : { serviceBoundaries: 'opt-in: pass meta.serviceEdges (dataflow/service-edges.js) with two or more declared services; the IR itself carries no service-boundary facts', storageEdges: 'opt-in: pass meta.serviceEdges (dataflow/service-edges.js); the IR itself carries no persistence facts' }),
|
|
231
|
+
sanitizers: 'sanitizer decisions are made by the dataflow layer (sanitizer-gate.js), not recorded in the IR; they are folded in by E2-08',
|
|
232
|
+
guards: 'guard dominance is computed by dataflow/guard-dominance.js, not recorded in the IR; it is folded in by E2-08',
|
|
233
|
+
};
|
|
234
|
+
const REASON_POP = { serviceBoundaries: 'HTTP and messaging relationships between declared services, and the ones with no counterpart', storageEdges: 'tables one declared service writes and another reads', symbols: 'one per IR function and per file top level', callSites: 'every call recorded in an IR function, plus calls nested inside assignment sources', allocations: 'object, array and function-valued expressions in call arguments and assignments', fieldAccesses: 'member reads in arguments and assignments, and dotted writes', valueOrigins: 'every call argument and every assignment node in the CFG of a function' };
|
|
235
|
+
const coverage = {};
|
|
236
|
+
for (const k of ENTITY_LISTS) coverage[k] = SCHEMA_ONLY[k] ? { state: 'schema-only', reason: SCHEMA_ONLY[k] } : g[k].length ? { state: 'populated', reason: REASON_POP[k] } : { state: 'schema-only', reason: `no ${k} in this input` };
|
|
237
|
+
const limits = [
|
|
238
|
+
'columns are null: the IR carries line numbers only',
|
|
239
|
+
'a call resolves only to a same-file function of the same name; imports, re-exports and methods are unresolved until E2-02',
|
|
240
|
+
'heap, alias and closure facts are not in the IR, so field accesses are syntactic, not points-to results',
|
|
241
|
+
];
|
|
242
|
+
// Dynamic constructs the analysis cannot resolve, per function (E2-03): the graph says where precision was given up, instead of leaving it implicit.
|
|
243
|
+
const dynamicFeatures = analysisLimits(projectIR).map((l) => ({ symbol: symByQid.get(`${l.qid}@${(projectIR.perFile[l.file].functions.find((f) => stripLine(f.qid) === l.qid) || {}).line}`) ?? null, file: l.file, line: l.line, kind: l.kind, detail: l.detail, effect: l.effect }));
|
|
244
|
+
return { schema: SEMANTIC_GRAPH_SCHEMA, schemaVersion: SEMANTIC_GRAPH_VERSION, producer: { layer: 'ir-v1', engineVersion: meta.engineVersion ?? null }, ...g, coverage, limits, dynamicFeatures };
|
|
245
|
+
}
|
|
@@ -15,6 +15,8 @@ export const HS_FAMILY_BY_CWE = Object.freeze({
|
|
|
15
15
|
'CWE-89': 'sql-injection',
|
|
16
16
|
'CWE-918': 'ssrf',
|
|
17
17
|
'CWE-79': 'xss',
|
|
18
|
+
'CWE-601': 'open-redirect',
|
|
19
|
+
'CWE-94': 'code-injection',
|
|
18
20
|
});
|
|
19
21
|
|
|
20
22
|
export const isHaskellFile = (file) => typeof file === 'string' && HS_FILE.test(file);
|
|
@@ -26,7 +26,7 @@ const IS_MEMBER = /^Data\.(?:Set|HashSet|Map|Map\.Strict|HashMap\.Strict)\.membe
|
|
|
26
26
|
const NOT_MEMBER = /^Data\.(?:Set|HashSet|Map|Map\.Strict|HashMap\.Strict)\.notMember$/;
|
|
27
27
|
const CHARCLASS = new Set(['Data.Char.isAlphaNum', 'Data.Char.isAlpha', 'Data.Char.isDigit', 'Data.Char.isAscii', 'isAlphaNum', 'isAlpha', 'isDigit']);
|
|
28
28
|
|
|
29
|
-
const FAMILY_BY_CWE = { 'CWE-22': 'path', 'CWE-918': 'url', 'CWE-78': 'cmd', 'CWE-88': 'cmd', 'CWE-89': 'sql', 'CWE-79': 'xss', 'CWE-1427': 'prompt' };
|
|
29
|
+
const FAMILY_BY_CWE = { 'CWE-22': 'path', 'CWE-918': 'url', 'CWE-78': 'cmd', 'CWE-88': 'cmd', 'CWE-89': 'sql', 'CWE-79': 'xss', 'CWE-601': 'redirect', 'CWE-94': 'code', 'CWE-1427': 'prompt' };
|
|
30
30
|
|
|
31
31
|
const isStr = (e) => e && e.kind === 'literal' && typeof e.value === 'string';
|
|
32
32
|
|