@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,174 @@
|
|
|
1
|
+
// Static service relationships (E2-07): which service calls, publishes to, or stores for which other service, read from the code.
|
|
2
|
+
//
|
|
3
|
+
// OPT-IN. Nothing here runs unless a caller passes the service roots; a single-service repository gets nothing, and no relationship is invented
|
|
4
|
+
// from a naming coincidence alone.
|
|
5
|
+
//
|
|
6
|
+
// WHAT AN EDGE CARRIES. Each edge says how it is known:
|
|
7
|
+
// observed both ends are literal in the code and agree (an HTTP client calling `http://orders/api/items` and an `orders` route `POST /api/items`;
|
|
8
|
+
// a producer and a consumer naming the same literal topic; two services naming the same table AND the project declaring one shared database)
|
|
9
|
+
// inferred the ends match but one rests on a weaker fact (two services name the same table with no declaration that they share a database;
|
|
10
|
+
// a client URL whose host comes from a configured base URL)
|
|
11
|
+
// unknown one end is dynamic (a computed topic, a URL assembled from user data) or has no counterpart: it is recorded and never connected
|
|
12
|
+
//
|
|
13
|
+
// WHAT IT NEVER SAYS. A chain is `static-observed` only when every hop is observed, `static-inferred` when the weakest hop is inferred, and
|
|
14
|
+
// `unproven` is reserved for a caller that supplies its own edges: this module never connects across an unknown one. No label is ever `proven`: this module reads text, it does not run the services, so ambiguity across
|
|
15
|
+
// a service boundary cannot be described as a demonstrated exploit chain. Traversal is bounded by hop count and a visited set, so a cycle ends.
|
|
16
|
+
//
|
|
17
|
+
// SCOPE. JavaScript, TypeScript and Python sources. HTTP (Express/Koa/Fastify-style routes, Flask/FastAPI decorators; fetch/axios/requests/got
|
|
18
|
+
// clients), messaging (Kafka/AMQP/Redis/NATS style publish and subscribe with a literal topic), persistence (SQL text and common collection calls).
|
|
19
|
+
// Serializer contracts (protobuf/JSON-schema sharing) are not read: recorded as a limit.
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
const EDGE_KINDS = Object.freeze(['http', 'messaging', 'persistence']);
|
|
23
|
+
const EVIDENCE = Object.freeze(['observed', 'inferred', 'unknown']);
|
|
24
|
+
export const MAX_HOPS = 6;
|
|
25
|
+
const RANK = { observed: 0, inferred: 1, unknown: 2 };
|
|
26
|
+
const id = (parts) => `svcedge:${createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16)}`;
|
|
27
|
+
const posix = (p) => String(p).replace(/\\/g, '/');
|
|
28
|
+
const lineOf = (code, index) => code.slice(0, index).split('\n').length;
|
|
29
|
+
|
|
30
|
+
function serviceOf(file, services) {
|
|
31
|
+
const f = posix(file);
|
|
32
|
+
let best = null;
|
|
33
|
+
for (const s of services) { const root = posix(s.root).replace(/\/$/, ''); if ((f === root || f.startsWith(`${root}/`)) && (!best || root.length > posix(best.root).length)) best = s; }
|
|
34
|
+
return best;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const norm = (p) => `/${String(p).replace(/^https?:\/\/[^/]+/, '').replace(/[?#].*$/, '').split('/').filter(Boolean).join('/')}`;
|
|
38
|
+
// `/items/:id`, `/items/{id}`, `/items/<id>`, `/items/${id}` all name the same shape
|
|
39
|
+
// a concrete segment (`/items/42`) matches a parameter segment of a route; two concrete segments must be equal
|
|
40
|
+
const matchesShape = (routePath, clientPath) => { const r = shape(routePath).split('/'); const c = shape(clientPath).split('/'); return r.length === c.length && r.every((seg, i) => seg === ':p' || seg === c[i]); };
|
|
41
|
+
const shape = (p) => norm(p).split('/').map((seg) => (/^(?::|\{|<|\$\{)/.test(seg) ? ':p' : seg)).join('/');
|
|
42
|
+
|
|
43
|
+
// ---- extraction -------------------------------------------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
const ROUTE_JS = /\b(?:app|router|server|fastify|api)\s*\.\s*(get|post|put|patch|delete|all)\s*\(\s*(['"`])(\/[^'"`]*)\2/g;
|
|
46
|
+
const ROUTE_PY = /@\s*(?:app|router|bp|blueprint)\s*\.\s*(get|post|put|patch|delete|route)\s*\(\s*(['"])(\/[^'"]*)\2([^)]*)\)/g;
|
|
47
|
+
const CLIENT_JS = /\b(fetch|axios(?:\s*\.\s*(?:get|post|put|patch|delete))?|got(?:\s*\.\s*(?:get|post|put|patch|delete))?)\s*\(\s*([`'"])([^`'"]*)\2/g;
|
|
48
|
+
const CLIENT_PY = /\brequests\s*\.\s*(get|post|put|patch|delete)\s*\(\s*(['"])([^'"]*)\2/g;
|
|
49
|
+
const CLIENT_DYN = /\b(?:fetch|axios(?:\s*\.\s*(?:get|post|put|patch|delete))?|requests\s*\.\s*(?:get|post|put|patch|delete)|got)\s*\(\s*([A-Za-z_$][\w$.]*)\s*[,)]/g;
|
|
50
|
+
const PRODUCE = /\b(?:producer|channel|publisher|pubsub|nats|client|redis|bus|queue)\s*\.\s*(?:send|publish|emit|produce|sendToQueue|xadd|lpush)\s*\(\s*(?:\{\s*topic\s*:\s*)?(['"`])([^'"`]+)\1/g;
|
|
51
|
+
const PRODUCE_DYN = /\b(?:producer|channel|publisher|pubsub|nats|bus|queue)\s*\.\s*(?:send|publish|emit|produce|sendToQueue)\s*\(\s*(?:\{\s*topic\s*:\s*)?([A-Za-z_$][\w$.]*)\s*[,})]/g;
|
|
52
|
+
const CONSUME = /\b(?:consumer|channel|subscriber|pubsub|nats|bus|queue|client)\s*\.\s*(?:subscribe|consume|on|psubscribe|xread|brpop)\s*\(\s*(?:\{\s*topic\s*:\s*)?(['"`])([^'"`]+)\1/g;
|
|
53
|
+
const SQL_WRITE = /\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+[`"']?([A-Za-z_][\w.]*)/gi;
|
|
54
|
+
const SQL_READ = /\bSELECT\b[^;]{0,300}?\bFROM\s+[`"']?([A-Za-z_][\w.]*)/gi;
|
|
55
|
+
const COLL_WRITE = /\b(?:db|database|mongo)\s*\.\s*(?:collection\s*\(\s*['"`]([\w.-]+)['"`]\s*\)|([A-Za-z_]\w*))\s*\.\s*(?:insertOne|insertMany|updateOne|updateMany|deleteOne|deleteMany|save|create|insert|update|remove)\s*\(/g;
|
|
56
|
+
const COLL_READ = /\b(?:db|database|mongo)\s*\.\s*(?:collection\s*\(\s*['"`]([\w.-]+)['"`]\s*\)|([A-Za-z_]\w*))\s*\.\s*(?:find|findOne|findMany|findById|get|select|aggregate|count)\s*\(/g;
|
|
57
|
+
const DB_URL = /\b(DATABASE_URL|DB_URL|MONGO_URL|MONGODB_URI|POSTGRES_URL|REDIS_URL)\b/g;
|
|
58
|
+
|
|
59
|
+
function* matches(re, code) { re.lastIndex = 0; let m; while ((m = re.exec(code))) yield m; }
|
|
60
|
+
|
|
61
|
+
/** @returns {{routes:object[], clients:object[], produces:object[], consumes:object[], writes:object[], reads:object[], dbRefs:string[], unknown:object[]}} */
|
|
62
|
+
export function extractFacts(file, code) {
|
|
63
|
+
const out = { routes: [], clients: [], produces: [], consumes: [], writes: [], reads: [], dbRefs: [], unknown: [] };
|
|
64
|
+
const py = /\.py$/.test(file);
|
|
65
|
+
const at = (m) => lineOf(code, m.index);
|
|
66
|
+
for (const m of matches(py ? ROUTE_PY : ROUTE_JS, code)) {
|
|
67
|
+
let method = m[1].toUpperCase();
|
|
68
|
+
if (py && method === 'ROUTE') { const mm = /methods\s*=\s*\[\s*['"](\w+)['"]/.exec(m[4] || ''); method = mm ? mm[1].toUpperCase() : 'GET'; }
|
|
69
|
+
out.routes.push({ method, path: m[3], file, line: at(m) });
|
|
70
|
+
}
|
|
71
|
+
for (const m of matches(py ? CLIENT_PY : CLIENT_JS, code)) {
|
|
72
|
+
const target = py ? m[3] : m[3];
|
|
73
|
+
// `fetch(url, { method: 'POST' })` names its verb in the options object; `axios.post` and `requests.post` in the callee
|
|
74
|
+
const optionVerb = /\bmethod\s*:\s*['"`](\w+)['"`]/.exec(code.slice(m.index, m.index + 300));
|
|
75
|
+
const verb = py ? m[1].toUpperCase() : ((/\.\s*(get|post|put|patch|delete)/.exec(m[1]) || [null, optionVerb ? optionVerb[1] : 'GET'])[1]).toUpperCase();
|
|
76
|
+
if (/^https?:\/\//.test(target) || /^\$\{[^}]+\}\//.test(target)) out.clients.push({ method: verb, url: target, file, line: at(m), literalHost: /^https?:\/\/[^/${]+/.test(target) });
|
|
77
|
+
}
|
|
78
|
+
for (const m of matches(CLIENT_DYN, code)) out.unknown.push({ kind: 'http', file, line: at(m), reason: `client URL comes from \`${m[1]}\`, not a literal` });
|
|
79
|
+
for (const m of matches(PRODUCE, code)) out.produces.push({ topic: m[2], file, line: at(m) });
|
|
80
|
+
for (const m of matches(PRODUCE_DYN, code)) out.unknown.push({ kind: 'messaging', file, line: at(m), reason: `topic comes from \`${m[1]}\`, not a literal` });
|
|
81
|
+
for (const m of matches(CONSUME, code)) out.consumes.push({ topic: m[2], file, line: at(m) });
|
|
82
|
+
for (const m of matches(SQL_WRITE, code)) out.writes.push({ table: m[1].toLowerCase(), file, line: at(m) });
|
|
83
|
+
for (const m of matches(SQL_READ, code)) out.reads.push({ table: m[1].toLowerCase(), file, line: at(m) });
|
|
84
|
+
for (const m of matches(COLL_WRITE, code)) out.writes.push({ table: String(m[1] || m[2]).toLowerCase(), file, line: at(m) });
|
|
85
|
+
for (const m of matches(COLL_READ, code)) out.reads.push({ table: String(m[1] || m[2]).toLowerCase(), file, line: at(m) });
|
|
86
|
+
for (const m of matches(DB_URL, code)) out.dbRefs.push(m[1]);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {Record<string,string>} fileContents repository-relative path -> source
|
|
92
|
+
* @param {{services:{name:string, root:string, hosts?:string[]}[], sharedDatabases?:{name?:string, services:string[], tables?:string[]}[]}} opts
|
|
93
|
+
* `services` is REQUIRED: without it nothing is inferred. `hosts` are the names other services use to reach this one.
|
|
94
|
+
* `sharedDatabases` is the declaration that makes a persistence edge observed rather than inferred.
|
|
95
|
+
*/
|
|
96
|
+
export function buildServiceEdges(fileContents, opts = {}) {
|
|
97
|
+
const services = (opts.services || []).filter((s) => s && s.name && s.root);
|
|
98
|
+
if (services.length < 2) return { edges: [], unknown: [], services: services.map((s) => s.name), limits: LIMITS, optedIn: false, note: 'fewer than two services declared: no service relationships are inferred' };
|
|
99
|
+
const facts = new Map(services.map((s) => [s.name, { routes: [], clients: [], produces: [], consumes: [], writes: [], reads: [], dbRefs: new Set(), unknown: [] }]));
|
|
100
|
+
for (const [file, code] of Object.entries(fileContents || {}).sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
101
|
+
if (typeof code !== 'string' || !/\.(?:[cm]?[jt]sx?|py)$/.test(file)) continue;
|
|
102
|
+
const svc = serviceOf(file, services);
|
|
103
|
+
if (!svc) continue;
|
|
104
|
+
const f = extractFacts(posix(file), code);
|
|
105
|
+
const acc = facts.get(svc.name);
|
|
106
|
+
for (const k of ['routes', 'clients', 'produces', 'consumes', 'writes', 'reads', 'unknown']) acc[k].push(...f[k].map((x) => ({ ...x, service: svc.name })));
|
|
107
|
+
for (const d of f.dbRefs) acc.dbRefs.add(d);
|
|
108
|
+
}
|
|
109
|
+
const hostsOf = (s) => new Set([s.name, ...(s.hosts || [])].map((h) => h.toLowerCase()));
|
|
110
|
+
const edges = []; const unknown = [];
|
|
111
|
+
for (const [name, f] of facts) for (const u of f.unknown) unknown.push({ service: name, ...u, evidence: 'unknown' });
|
|
112
|
+
|
|
113
|
+
// HTTP: a client URL names a host and a path; the host picks the service, the method and path shape pick the route
|
|
114
|
+
for (const [from, f] of facts) for (const c of f.clients) {
|
|
115
|
+
const hm = /^https?:\/\/([^/:${]+)/.exec(c.url);
|
|
116
|
+
const host = hm ? hm[1].toLowerCase() : null;
|
|
117
|
+
const target = host ? services.find((s) => s.name !== from && hostsOf(s).has(host)) : null;
|
|
118
|
+
if (!target) { unknown.push({ service: from, kind: 'http', file: c.file, line: c.line, reason: host ? `host ${host} is not a declared service` : 'the host is configured at run time', evidence: 'unknown' }); continue; }
|
|
119
|
+
const route = facts.get(target.name).routes.find((r) => (r.method === c.method || r.method === 'ALL') && matchesShape(r.path, c.url));
|
|
120
|
+
if (!route) { unknown.push({ service: from, kind: 'http', file: c.file, line: c.line, reason: `${target.name} has no ${c.method} route matching ${norm(c.url)}`, evidence: 'unknown' }); continue; }
|
|
121
|
+
edges.push({ id: id(['http', from, c.file, c.method, shape(c.url), target.name, route.file]), kind: 'http', from: { service: from, file: c.file, line: c.line }, to: { service: target.name, file: route.file, line: route.line }, via: { method: c.method, path: shape(route.path) }, evidence: 'observed', basis: 'a literal client URL and a route with the same method and path shape' });
|
|
122
|
+
}
|
|
123
|
+
// Messaging: the same literal topic on a producer and a consumer in different services
|
|
124
|
+
for (const [from, f] of facts) for (const p of f.produces) {
|
|
125
|
+
const consumers = [...facts].filter(([n]) => n !== from).flatMap(([n, g]) => g.consumes.filter((c) => c.topic === p.topic).map((c) => ({ ...c, service: n })));
|
|
126
|
+
if (!consumers.length) { unknown.push({ service: from, kind: 'messaging', file: p.file, line: p.line, reason: `no other service consumes topic "${p.topic}"`, evidence: 'unknown' }); continue; }
|
|
127
|
+
for (const c of consumers) edges.push({ id: id(['messaging', from, p.file, p.topic, c.service, c.file]), kind: 'messaging', from: { service: from, file: p.file, line: p.line }, to: { service: c.service, file: c.file, line: c.line }, via: { topic: p.topic }, evidence: 'observed', basis: 'the same literal topic is produced and consumed' });
|
|
128
|
+
}
|
|
129
|
+
// Persistence: one service writes a table another reads
|
|
130
|
+
const shared = (opts.sharedDatabases || []).map((d) => ({ services: new Set(d.services), tables: d.tables ? new Set(d.tables.map((t) => t.toLowerCase())) : null }));
|
|
131
|
+
for (const [from, f] of facts) for (const w of f.writes) for (const [to, g] of facts) {
|
|
132
|
+
if (to === from) continue;
|
|
133
|
+
for (const r of g.reads.filter((x) => x.table === w.table)) {
|
|
134
|
+
const declared = shared.some((d) => d.services.has(from) && d.services.has(to) && (!d.tables || d.tables.has(w.table)));
|
|
135
|
+
edges.push({ id: id(['persistence', from, w.file, w.table, to, r.file]), kind: 'persistence', from: { service: from, file: w.file, line: w.line }, to: { service: to, file: r.file, line: r.line }, via: { table: w.table }, evidence: declared ? 'observed' : 'inferred', basis: declared ? 'both services use the table and the project declares them as sharing a database' : 'both services name the table, but nothing declares that they share a database' });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
edges.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
139
|
+
unknown.sort((a, b) => (a.service + a.file + a.line < b.service + b.file + b.line ? -1 : 1));
|
|
140
|
+
return { edges, unknown, services: services.map((s) => s.name), limits: LIMITS, optedIn: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const LIMITS = Object.freeze([
|
|
144
|
+
'only JavaScript, TypeScript and Python sources are read',
|
|
145
|
+
'a topic, URL host or table assembled at run time is recorded as unknown and never connected',
|
|
146
|
+
'serializer contracts (protobuf, JSON schema) are not read, so a shared message shape is not an edge',
|
|
147
|
+
'an edge says two services are related in the code; it does not say attacker data crosses it. That needs a finding on each side',
|
|
148
|
+
'no chain is ever labelled proven: this module reads text and does not run the services',
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Bounded breadth-first trace from a service. Each path carries the weakest evidence on it. A path through an inferred edge is
|
|
153
|
+
* `static-inferred`; the label `static-observed` needs every hop observed; nothing is `proven`.
|
|
154
|
+
*/
|
|
155
|
+
export function traceFrom(graph, start, { maxHops = MAX_HOPS, maxPaths = 200 } = {}) {
|
|
156
|
+
const hops = Math.max(1, Math.min(MAX_HOPS, maxHops));
|
|
157
|
+
const byFrom = new Map();
|
|
158
|
+
for (const e of graph.edges) { if (!byFrom.has(e.from.service)) byFrom.set(e.from.service, []); byFrom.get(e.from.service).push(e); }
|
|
159
|
+
const out = []; let truncated = false;
|
|
160
|
+
const queue = [{ at: start, path: [], seen: new Set([start]) }];
|
|
161
|
+
while (queue.length) {
|
|
162
|
+
const cur = queue.shift();
|
|
163
|
+
if (cur.path.length >= hops) { if ((byFrom.get(cur.at) || []).length) truncated = true; continue; }
|
|
164
|
+
for (const e of byFrom.get(cur.at) || []) {
|
|
165
|
+
if (cur.seen.has(e.to.service)) continue;
|
|
166
|
+
const path = [...cur.path, e];
|
|
167
|
+
if (out.length >= maxPaths) { truncated = true; break; }
|
|
168
|
+
const weakest = path.reduce((w, x) => (RANK[x.evidence] > RANK[w] ? x.evidence : w), 'observed');
|
|
169
|
+
out.push({ services: [start, ...path.map((x) => x.to.service)], edges: path.map((x) => x.id), kinds: path.map((x) => x.kind), evidence: weakest, label: weakest === 'observed' ? 'static-observed' : weakest === 'inferred' ? 'static-inferred' : 'unproven' });
|
|
170
|
+
queue.push({ at: e.to.service, path, seen: new Set([...cur.seen, e.to.service]) });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { start, paths: out, truncated, maxHops: hops };
|
|
174
|
+
}
|
|
@@ -116,9 +116,14 @@ export class SummaryCache {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
get(qid, taintedParams, receiverType) {
|
|
119
|
-
|
|
119
|
+
const r = this._cache.get(this._key(qid, taintedParams, receiverType));
|
|
120
|
+
if (r === undefined) this._misses = (this._misses || 0) + 1; else this._hits = (this._hits || 0) + 1;
|
|
121
|
+
return r;
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
/** Lookups that found and did not find a summary (E6-01). */
|
|
125
|
+
stats() { return { hits: this._hits || 0, misses: this._misses || 0, size: this._cache.size }; }
|
|
126
|
+
|
|
122
127
|
set(qid, taintedParams, summary, receiverType) {
|
|
123
128
|
const k = this._key(qid, taintedParams, receiverType);
|
|
124
129
|
this._cache.set(k, summary);
|
|
@@ -163,6 +168,7 @@ export class SummaryCache {
|
|
|
163
168
|
return { returnTainted: false, mutatedParams: new Set(), taintedGlobals: new Set(), findings: [], _recursive: true };
|
|
164
169
|
}
|
|
165
170
|
if (++this._iter > this._maxIter) {
|
|
171
|
+
this._budgetHits = (this._budgetHits || 0) + 1;
|
|
166
172
|
return { returnTainted: false, mutatedParams: new Set(), taintedGlobals: new Set(), findings: [], _budgetExceeded: true };
|
|
167
173
|
}
|
|
168
174
|
this._stack.add(qid);
|
package/src/discovery/judge.js
CHANGED
|
@@ -51,7 +51,8 @@ export function toFindingShape(candidate) {
|
|
|
51
51
|
description: candidate.rationale
|
|
52
52
|
? `${candidate.rationale} (entry point: ${candidate.entryPoint || 'unstated'}; sink: ${candidate.sink || 'unstated'})`
|
|
53
53
|
: `Proposed by the ${candidate.lens} lens; no rationale supplied.`,
|
|
54
|
-
|
|
54
|
+
// a candidate that carries its own concrete remediation (a deterministic finding with a known fix shape) keeps it; every other candidate gets the generic review text
|
|
55
|
+
remediation: (typeof candidate.remediation === 'string' && candidate.remediation) ? candidate.remediation : `Review ${candidate.file}:${candidate.line}. Confirm whether ${candidate.entryPoint || 'attacker-controlled input'} can reach ${candidate.sink || 'this operation'}, and constrain it at the boundary if so.`,
|
|
55
56
|
parser: 'DISCOVERY',
|
|
56
57
|
family: candidate.family || 'other',
|
|
57
58
|
ruleId: `discovery:${candidate.lens}`,
|
package/src/engine.js
CHANGED
|
@@ -58,7 +58,8 @@ import { proofCoverage } from './posture/proof-coverage.js';
|
|
|
58
58
|
import { computeScanHealth, applyFreshness } from './pipeline/scan-health.js';
|
|
59
59
|
import { computeCoverageLedger, summarizeCoverageForScanHealth } from './pipeline/coverage-ledger.js';
|
|
60
60
|
import { runAnnotatorAsync } from './pipeline/annotator-runner.js';
|
|
61
|
-
import { runDetector } from './pipeline/detector-runner.js';
|
|
61
|
+
import { runDetector, setDetectorTimingSink } from './pipeline/detector-runner.js';
|
|
62
|
+
import { createTelemetry, attributeErrors } from './pipeline/telemetry.js';
|
|
62
63
|
import { registerProducer } from './pipeline/producer-registry.js';
|
|
63
64
|
import { collectProducerResult } from './pipeline/producer-collector.js';
|
|
64
65
|
import { completeEnrichment } from './pipeline/enrichment-completion.js';
|
|
@@ -129,6 +130,8 @@ import { scanClientSide } from './sast/client-side.js';
|
|
|
129
130
|
import { scanPromptFirewall } from './sast/prompt-firewall.js';
|
|
130
131
|
import { scanLlmRedteam } from './posture/llm-redteam.js';
|
|
131
132
|
import { assessPrivacyFramework, persistPrivacyFramework } from './posture/privacy-framework.js';
|
|
133
|
+
import { signLastScan } from './posture/integrity.js';
|
|
134
|
+
import { secretCanaryHit } from './posture/runtime-trace-ingest.js';
|
|
132
135
|
import { safeWriteState as _safeWriteState, statePath, statePath as _statePath } from './posture/state-dir.js';
|
|
133
136
|
import { scanContainer } from './sca/container.js';
|
|
134
137
|
import { detectDepConfusion } from './sca/dep-confusion.js';
|
|
@@ -210,6 +213,7 @@ import { applyPathConstraints } from './posture/path-predicates.js';
|
|
|
210
213
|
import { buildProjectIR, buildProjectIRAsync } from './ir/index.js';
|
|
211
214
|
import _v8 from 'node:v8';
|
|
212
215
|
import _vm from 'node:vm';
|
|
216
|
+
import { createStringMemo } from './util/string-memo.js';
|
|
213
217
|
import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
|
|
214
218
|
import { runDeepAnalysis } from './dataflow/index.js';
|
|
215
219
|
import { buildLineageGraph } from './lineage/index.js';
|
|
@@ -446,7 +450,11 @@ function _ruleAppliesIn(pat, ctx){
|
|
|
446
450
|
// comments would put the second pass into a stuck string state, blanking the
|
|
447
451
|
// rest of the file. Backtick `${...}` template expressions are preserved
|
|
448
452
|
// verbatim because they contain real source/sink references.
|
|
449
|
-
|
|
453
|
+
// Called by several detectors per file with the same text; the result depends only on the text, so it is computed once per distinct text
|
|
454
|
+
// (util/string-memo.js). AGENTIC_SECURITY_NO_STRING_MEMO=1 turns the memo off.
|
|
455
|
+
const _stripNoiseAndStringsMemo = createStringMemo((c) => _stripNoiseAndStringsRaw(c), { version: 'stripNoiseAndStrings@1', disableEnv: 'AGENTIC_SECURITY_NO_STRING_MEMO' });
|
|
456
|
+
function stripNoiseAndStrings(code){ return _stripNoiseAndStringsMemo(code); }
|
|
457
|
+
function _stripNoiseAndStringsRaw(code){
|
|
450
458
|
const out = code.split('');
|
|
451
459
|
const n = code.length;
|
|
452
460
|
let i = 0;
|
|
@@ -3134,6 +3142,27 @@ function getProjectConstant(name){
|
|
|
3134
3142
|
return hit ? hit.value : null;
|
|
3135
3143
|
}
|
|
3136
3144
|
|
|
3145
|
+
// E6-04: a digest of every input OUTSIDE a single file that the per-file detector cascade reads: the project index (constants, properties, the
|
|
3146
|
+
// e-commerce flag), the Java tainted-method index, and the operator's rules file with its signature. A cached per-file result is reused only when this
|
|
3147
|
+
// is unchanged, so a change to a constant in one file cannot leave another file's cached answer standing.
|
|
3148
|
+
const _ANALYSIS_CACHE_NEVER_RE = /(?:^|[\\/])\.?mcp(?:\.[a-z]+)?\.json$|(?:^|[\\/])\.mcp\.json$/i;
|
|
3149
|
+
function _analysisContextDigest(scanRoot){
|
|
3150
|
+
const h = crypto.createHash('sha256');
|
|
3151
|
+
const mapEntries = (m) => [...(m || new Map()).entries()].map(([k, v]) => [k, v]).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
3152
|
+
h.update(JSON.stringify({
|
|
3153
|
+
ecommerce: !!_projectIndex.hasEcommerceModel,
|
|
3154
|
+
byFile: mapEntries(_projectIndex.constantsByFile),
|
|
3155
|
+
byExport: mapEntries(_projectIndex.constantsByExport),
|
|
3156
|
+
props: mapEntries(_projectIndex.javaProperties),
|
|
3157
|
+
javaTainted: [..._GLOBAL_JAVA_TAINTED_METHODS].sort(),
|
|
3158
|
+
}));
|
|
3159
|
+
for (const name of ['rules.yml', 'rules.yaml', 'rules.json', 'rules.yml.sig', 'rules.yaml.sig', 'rules.json.sig']) {
|
|
3160
|
+
h.update(`\n${name}\0`);
|
|
3161
|
+
try { h.update(fs.readFileSync(statePath(scanRoot, name))); } catch { h.update('absent'); }
|
|
3162
|
+
}
|
|
3163
|
+
return h.digest('hex');
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3137
3166
|
const _COUPON_MUTATION_RE = /\b(?:apply|redeem|validate|use|deduct|decrement|increment|update|save|consume|create|destroy|remove|insert)\b/i;
|
|
3138
3167
|
const _REAUTH_PRESENT_RE = /\b(?:bcrypt\.compare|crypto\.timingSafeEqual|currentPassword|recentLogin|verifyTotp|verifyMfa|mfaToken|requireRecentLogin|requireFreshSession|step[-_]?up|requireFreshAuth|verifyPassword)\b/;
|
|
3139
3168
|
|
|
@@ -8859,7 +8888,12 @@ function _gcHint(minHeapMiB) {
|
|
|
8859
8888
|
_gcFn();
|
|
8860
8889
|
}
|
|
8861
8890
|
|
|
8862
|
-
async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined, provenance=true, completeScan=true, skipAnnotators=false, policyExcluded=[], sizeSkipped=[]}, setProgress=()=>{}){
|
|
8891
|
+
async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined, provenance=true, completeScan=true, skipAnnotators=false, policyExcluded=[], sizeSkipped=[]}, setProgress=()=>{}){
|
|
8892
|
+
// E6-01: local, aggregate telemetry for this scan (see pipeline/telemetry.js). Nothing is sent anywhere; the snapshot is a field of the result.
|
|
8893
|
+
const _tel = createTelemetry({ deterministic: isDeterministic() });
|
|
8894
|
+
setDetectorTimingSink((name, ms) => _tel.detector(name, ms));
|
|
8895
|
+
_tel.sample();
|
|
8896
|
+
_resetSuppressions();_tel.time('index', () => _buildProjectIndex(fileContents));await _tel.timeAsync('custom-rules', () => _loadCustomRules(scanRoot));
|
|
8863
8897
|
// Pre-pass: build cross-file Java tainted-method index so per-file taint
|
|
8864
8898
|
// analysis can recognize calls to user-input-returning helper methods
|
|
8865
8899
|
// defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
|
|
@@ -8885,17 +8919,20 @@ function _deterministicFileTimings(timings) {
|
|
|
8885
8919
|
}
|
|
8886
8920
|
|
|
8887
8921
|
const _fileTimings = [];
|
|
8888
|
-
let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0;
|
|
8922
|
+
let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0, _filesOversizeSkipped = 0;
|
|
8889
8923
|
const files=Object.keys(fileContents).filter(f=>(shouldScan(f) || isKubernetesManifest(f, fileContents[f]) || isCloudFormationTemplate(f, fileContents[f]) || isInstructionFile(f)) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];const _detectorErrors=[];
|
|
8890
8924
|
// ---- R8: opt-in per-file checkpointing (AGENTIC_SECURITY_RESUME=1, or
|
|
8891
8925
|
// runScan({resume:true})). Default OFF, so existing behaviour is untouched.
|
|
8892
8926
|
// Only this loop is checkpointed; every cross-file pass below re-runs, so
|
|
8893
8927
|
// nothing that depends on the whole tree can be stale on resume.
|
|
8894
8928
|
const _ckptEnabled = (resume === undefined ? process.env.AGENTIC_SECURITY_RESUME === '1' : !!resume) && !!scanRoot;
|
|
8929
|
+
// E6-04: opt-in cross-scan analysis cache (AGENTIC_SECURITY_ANALYSIS_CACHE=1). Same per-file record format as the resume checkpoint, but it is kept after
|
|
8930
|
+
// a completed scan, its records are authenticated with the install key, and the global key also covers the cross-file inputs a file's cascade reads.
|
|
8931
|
+
const _cacheEnabled = !_ckptEnabled && process.env.AGENTIC_SECURITY_ANALYSIS_CACHE === '1' && !!scanRoot;
|
|
8895
8932
|
let _ckpt = null; const _ckptPayloads = new Map(); let _ckptDone = new Set(); let _ckptResumed = 0, _ckptWrites = 0;
|
|
8896
8933
|
const _ckptAbortAfter = parseInt(process.env.AGENTIC_SECURITY_CHECKPOINT_ABORT_AFTER || '0', 10) || 0;
|
|
8897
8934
|
let _ckptInvalidated = [];
|
|
8898
|
-
if (_ckptEnabled) {
|
|
8935
|
+
if (_ckptEnabled || _cacheEnabled) {
|
|
8899
8936
|
try {
|
|
8900
8937
|
const _ckptIdentity = {
|
|
8901
8938
|
engineVersion: _ENGINE_VERSION,
|
|
@@ -8909,8 +8946,9 @@ function _deterministicFileTimings(timings) {
|
|
|
8909
8946
|
// instead, so changing one file no longer discards every other file's
|
|
8910
8947
|
// already-completed work — see scan-checkpoint.js's module header.
|
|
8911
8948
|
_ckpt = openCheckpoint(scanRoot, {
|
|
8912
|
-
globalKey: computeGlobalKey(_ckptIdentity),
|
|
8949
|
+
globalKey: computeGlobalKey(_cacheEnabled ? { ..._ckptIdentity, extra: _analysisContextDigest(scanRoot) } : _ckptIdentity),
|
|
8913
8950
|
meta: globalKeyMeta(_ckptIdentity),
|
|
8951
|
+
...(_cacheEnabled ? { persistent: true, fileName: 'analysis-cache.jsonl', sign: signLastScan } : {}),
|
|
8914
8952
|
// Haskell/Nix sources hash with their import closure, so an imported
|
|
8915
8953
|
// module's change invalidates its importers' checkpointed work too.
|
|
8916
8954
|
fileContents: withLanguageClosure(fileContents, depFileContents),
|
|
@@ -8939,6 +8977,17 @@ function _deterministicFileTimings(timings) {
|
|
|
8939
8977
|
for (const x of (d.ciphersRest || [])) aCiphersRest.push(x);
|
|
8940
8978
|
for (const x of (d.ciphersTransit || [])) aCiphersTransit.push(x);
|
|
8941
8979
|
for (const x of (d.suppressions || [])) _suppressionLog.push(x);
|
|
8980
|
+
// A finding's `source` and `sink` ARE the entries of this file's source and sink lists (one object, two references), and later passes annotate them in
|
|
8981
|
+
// place (flowStatus, riskStatus, ...). Reading a record back from JSON gives each reference its own copy, so the annotation would reach the list entry
|
|
8982
|
+
// and not the finding. Re-pointing the finding at the matching list entry restores the sharing a fresh analysis has.
|
|
8983
|
+
{
|
|
8984
|
+
const srcByJson = new Map(); for (const x of aSrc.slice(s0)) srcByJson.set(JSON.stringify(x), x);
|
|
8985
|
+
const sinkByJson = new Map(); for (const x of aSink.slice(k0)) sinkByJson.set(JSON.stringify(x), x);
|
|
8986
|
+
for (const f of aF.slice(f0)) {
|
|
8987
|
+
if (f && f.source && typeof f.source === 'object') { const m = srcByJson.get(JSON.stringify(f.source)); if (m) f.source = m; }
|
|
8988
|
+
if (f && f.sink && typeof f.sink === 'object') { const m = sinkByJson.get(JSON.stringify(f.sink)); if (m) f.sink = m; }
|
|
8989
|
+
}
|
|
8990
|
+
}
|
|
8942
8991
|
const ta = Object.assign({}, d.pfr || {});
|
|
8943
8992
|
ta.findings = aF.slice(f0, f0 + (d.pfrFindings || 0));
|
|
8944
8993
|
ta.sources = aSrc.slice(s0); ta.sinks = aSink.slice(k0); ta.sanitizers = aSan.slice(n0);
|
|
@@ -8947,9 +8996,10 @@ function _deterministicFileTimings(timings) {
|
|
|
8947
8996
|
_ckptResumed++;
|
|
8948
8997
|
return true;
|
|
8949
8998
|
};
|
|
8999
|
+
let _cacheSecretSkips = 0;
|
|
8950
9000
|
const _ckptRecord = (p, mk, ms, ta) => {
|
|
8951
9001
|
if (!_ckpt || !_ckpt.enabled) return;
|
|
8952
|
-
|
|
9002
|
+
const _payload = {
|
|
8953
9003
|
routes: aR.slice(mk.aR), findings: aF.slice(mk.aF),
|
|
8954
9004
|
sources: aSrc.slice(mk.aSrc), sinks: aSink.slice(mk.aSink), sanitizers: aSan.slice(mk.aSan),
|
|
8955
9005
|
logic: aLogic.slice(mk.aLogic), secrets: aSecrets.slice(mk.aSecrets),
|
|
@@ -8958,7 +9008,10 @@ function _deterministicFileTimings(timings) {
|
|
|
8958
9008
|
pfr: _pfrMetaOnly(ta),
|
|
8959
9009
|
pfrFindings: (ta && Array.isArray(ta.findings)) ? ta.findings.length : 0,
|
|
8960
9010
|
ms,
|
|
8961
|
-
}
|
|
9011
|
+
};
|
|
9012
|
+
// A cache that outlives the scan must not become a second copy of a secret: a file whose result contains a secret-shaped value is analysed afresh each time.
|
|
9013
|
+
if (_cacheEnabled) { let _txt = ''; try { _txt = JSON.stringify(_payload); } catch { /* not serializable: recordFileDone refuses it too */ } if (secretCanaryHit(_txt)) { _cacheSecretSkips++; return; } }
|
|
9014
|
+
recordFileDone(_ckpt, p, _payload);
|
|
8962
9015
|
_ckptWrites++;
|
|
8963
9016
|
// Fault injection for the resume test: a hard exit with no unwinding, which
|
|
8964
9017
|
// is what the checkpoint format has to survive. Never set in normal use.
|
|
@@ -8978,12 +9031,13 @@ function _deterministicFileTimings(timings) {
|
|
|
8978
9031
|
});
|
|
8979
9032
|
}
|
|
8980
9033
|
let i=0;
|
|
9034
|
+
const _tDet = Date.now();
|
|
8981
9035
|
try {
|
|
8982
9036
|
for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});
|
|
8983
9037
|
if(_ckptDone.has(p)&&_ckptReplay(p))continue;
|
|
8984
9038
|
const _mk={aR:aR.length,aF:aF.length,aSrc:aSrc.length,aSink:aSink.length,aSan:aSan.length,aLogic:aLogic.length,aSecrets:aSecrets.length,aCR:aCiphersRest.length,aCT:aCiphersTransit.length,sup:_suppressionLog.length};
|
|
8985
|
-
try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}
|
|
8986
|
-
let _delta;
|
|
9039
|
+
try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;if(c&&c.length>500000)_filesOversizeSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}
|
|
9040
|
+
let _delta; const _errsBefore = _detectorErrors.length;
|
|
8987
9041
|
if (_cascadePool) {
|
|
8988
9042
|
const _res = await _cascadePool.runFile(p, c, scanRoot, _detectorErrors, { timeoutMs: _perFileTimeoutMs });
|
|
8989
9043
|
if (_res.ok) { _delta = _res.result; }
|
|
@@ -8997,7 +9051,9 @@ function _deterministicFileTimings(timings) {
|
|
|
8997
9051
|
const _ftElapsed=Date.now()-_ft0;
|
|
8998
9052
|
if(_ftElapsed>_perFileTimeoutMs){aF.push({id:`file-timeout:${p}`,file:p,line:0,vuln:`File analysis exceeded ${_perFileTimeoutMs}ms (${_ftElapsed}ms)`,severity:'info',parser:'ENGINE',confidence:0.5,_timeout:true});_filesTimedOut++;}
|
|
8999
9053
|
_fileTimings.push({file:p,ms:_ftElapsed});
|
|
9000
|
-
|
|
9054
|
+
// The cache only keeps a file whose analysis finished cleanly: a detector that threw, a timeout, or a file whose analysis reads or writes state outside
|
|
9055
|
+
// itself (the MCP rug-pull baseline) is analysed afresh every time so health reporting and side effects stay exactly as they are.
|
|
9056
|
+
if (!_cacheEnabled || (_detectorErrors.length === _errsBefore && _ftElapsed <= _perFileTimeoutMs && !_ANALYSIS_CACHE_NEVER_RE.test(p))) _ckptRecord(p,_mk,_ftElapsed,_delta.pfr);
|
|
9001
9057
|
}catch(_){_fileTimings.push({file:p,ms:Date.now()-_ft0,error:true});}
|
|
9002
9058
|
if(i%5===0)await new Promise(r=>setTimeout(r,0));}
|
|
9003
9059
|
} finally {
|
|
@@ -9146,6 +9202,8 @@ function _deterministicFileTimings(timings) {
|
|
|
9146
9202
|
};
|
|
9147
9203
|
filterArr(aF); filterArr(aLogic); filterArr(aSecrets); filterArr(aSupply);
|
|
9148
9204
|
}
|
|
9205
|
+
_tel.record('detectors-per-file', Date.now() - _tDet);
|
|
9206
|
+
const _tLink = Date.now();
|
|
9149
9207
|
setProgress({current:i,total:files.length,file:"Cross-file...",phase:"Linking"});const ii=buildImportGraph(fc);const cf=crossFileTaint(pfr,fc,ii);aF.push(...cf);
|
|
9150
9208
|
setProgress({current:i,total:files.length,file:"Stored taint...",phase:"Linking"});const storedRegistry=buildStoredTaintRegistry(fc);const stf=crossStoredTaint(fc,storedRegistry);aF.push(...stf);
|
|
9151
9209
|
setProgress({current:i,total:files.length,file:"Session taint...",phase:"Linking"});const sess=crossSessionTaint(fc);aF.push(...sess);
|
|
@@ -9153,6 +9211,7 @@ function _deterministicFileTimings(timings) {
|
|
|
9153
9211
|
// R19 (PRD §5): cross-route BOLA/BFLA over the aggregated route inventory.
|
|
9154
9212
|
// Convention deviation is PROJECT-scoped by design: the convention is a
|
|
9155
9213
|
// property of the codebase, not of one file (see convention-deviation.js).
|
|
9214
|
+
_tel.record('cross-file-linking', Date.now() - _tLink);
|
|
9156
9215
|
aF.push(...(runDetector(_detectorErrors,'<project>','scanConventionDeviationProject',()=>scanConventionDeviationProject(fc))||[]));
|
|
9157
9216
|
aF.push(...(runDetector(_detectorErrors,'<project>','scanApiBrokenAuthz',()=>scanApiBrokenAuthz(aR))||[]));
|
|
9158
9217
|
// R22 (PRD §5): cross-service edges inferred from code (client call → matched route).
|
|
@@ -9432,6 +9491,7 @@ function _deterministicFileTimings(timings) {
|
|
|
9432
9491
|
const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
|
|
9433
9492
|
let _deepCallGraph = null;
|
|
9434
9493
|
let _deepFailure = null;
|
|
9494
|
+
let _deepIncomplete = null;
|
|
9435
9495
|
if (_deepEnabled) {
|
|
9436
9496
|
const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
|
|
9437
9497
|
const t0 = Date.now();
|
|
@@ -9462,6 +9522,8 @@ function _deterministicFileTimings(timings) {
|
|
|
9462
9522
|
}),
|
|
9463
9523
|
});
|
|
9464
9524
|
const elapsed = Date.now() - t0;
|
|
9525
|
+
_tel.record('deep-analysis', elapsed);
|
|
9526
|
+
if (irFindings.analysisIncomplete && irFindings._summaryCache && typeof irFindings._summaryCache.stats === 'function') { const st = irFindings._summaryCache.stats(); _tel.set('cache.summary.hits', st.hits); _tel.set('cache.summary.misses', st.misses); }
|
|
9465
9527
|
if (elapsed > budgetMs) {
|
|
9466
9528
|
// We exceeded budget — surface a single info finding so operators see it.
|
|
9467
9529
|
aF.push({
|
|
@@ -9473,6 +9535,7 @@ function _deterministicFileTimings(timings) {
|
|
|
9473
9535
|
confidence: 0.5,
|
|
9474
9536
|
});
|
|
9475
9537
|
}
|
|
9538
|
+
if (Array.isArray(irFindings.analysisIncomplete) && irFindings.analysisIncomplete.length) _deepIncomplete = irFindings.analysisIncomplete;
|
|
9476
9539
|
for (const f of irFindings) {
|
|
9477
9540
|
f.unvalidated = true;
|
|
9478
9541
|
f.validator_verdict = 'unvalidated';
|
|
@@ -9514,6 +9577,8 @@ function _deterministicFileTimings(timings) {
|
|
|
9514
9577
|
? (_inCi ? 'requested, but running in CI without AGENTIC_SECURITY_DEEP_IN_CI=1' : 'unknown')
|
|
9515
9578
|
: (_inCi ? 'not requested (deep analysis defaults to off in CI)' : 'not requested')),
|
|
9516
9579
|
failure: _deepFailure,
|
|
9580
|
+
// E2-05: where the deep analysis stopped short (budget, deadline, function limit, a thrown analysis). Present only when something did.
|
|
9581
|
+
...(_deepIncomplete ? { incomplete: _deepIncomplete } : {}),
|
|
9517
9582
|
};
|
|
9518
9583
|
// Sub-project E, increment 5 — Data Flow Explorer lineage graph.
|
|
9519
9584
|
// DELIBERATELY INDEPENDENT of AGENTIC_SECURITY_DEEP/_deepEnabled above
|
|
@@ -9861,7 +9926,7 @@ function _deterministicFileTimings(timings) {
|
|
|
9861
9926
|
let _annotatorIdx = 0;
|
|
9862
9927
|
const _runAnnotator = (phase, fn) => {
|
|
9863
9928
|
setProgress({ phase: 'Annotating', current: ++_annotatorIdx, total: _ANNOTATOR_TOTAL_ESTIMATE, file: phase });
|
|
9864
|
-
return runAnnotatorAsync(_annotatorErrors, phase, fn);
|
|
9929
|
+
return _tel.timeAsync(`annotator:${phase}`, () => runAnnotatorAsync(_annotatorErrors, phase, fn));
|
|
9865
9930
|
};
|
|
9866
9931
|
await _runAnnotator('annotateStableIds', () => annotateStableIds(finalFindings));
|
|
9867
9932
|
await _runAnnotator("clusterByRootCause", () => { finalFindings = clusterByRootCause(finalFindings); });
|
|
@@ -10740,7 +10805,7 @@ function _deterministicFileTimings(timings) {
|
|
|
10740
10805
|
// seen when only N-of-those-candidates were actually analyzed.
|
|
10741
10806
|
// checkpoint.total intentionally keeps files.length — that field means the
|
|
10742
10807
|
// full candidate set for resume bookkeeping, a different, correct meaning.
|
|
10743
|
-
_scanMeta={filesScanned:Object.keys(fc).length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_deterministicFileTimings(_fileTimings),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),resumed:_ckptResumed,total:files.length,discarded:!!(_ckpt&&_ckpt.discarded),discardReason:(_ckpt&&_ckpt.discarded)?(_ckpt.reason||null):null,invalidatedFileCount:_ckptInvalidated.length,invalidatedFiles:_ckptInvalidated.slice(0,20),invalidatedFilesTruncated:_ckptInvalidated.length>20}};
|
|
10808
|
+
_scanMeta={filesScanned:Object.keys(fc).length,filesSkipped:_filesSkipped,filesOversizeSkipped:_filesOversizeSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_deterministicFileTimings(_fileTimings),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),...(_cacheEnabled?{kind:'analysis-cache',secretSkips:_cacheSecretSkips}:{}),resumed:_ckptResumed,total:files.length,discarded:!!(_ckpt&&_ckpt.discarded),discardReason:(_ckpt&&_ckpt.discarded)?(_ckpt.reason||null):null,invalidatedFileCount:_ckptInvalidated.length,invalidatedFiles:_ckptInvalidated.slice(0,20),invalidatedFilesTruncated:_ckptInvalidated.length>20}};
|
|
10744
10809
|
// R8: the scan completed, so the checkpoint has been fully consumed — remove
|
|
10745
10810
|
// it. Anything that threw before this point leaves it in place to resume from.
|
|
10746
10811
|
try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
|
|
@@ -11163,7 +11228,10 @@ function _deterministicFileTimings(timings) {
|
|
|
11163
11228
|
compliance: _complianceReport ? { stale: _complianceReport.summary?.stale || 0 } : null,
|
|
11164
11229
|
});
|
|
11165
11230
|
} // end if (!skipAnnotators) — FR-PROV-029
|
|
11166
|
-
|
|
11231
|
+
setDetectorTimingSink(null);
|
|
11232
|
+
attributeErrors(_tel, { annotatorErrors: _annotatorErrors, detectorErrors: (typeof _detectorErrors !== 'undefined' ? _detectorErrors : []), deepFailure: _deepFailure, filesTimedOut: (_scanMeta && _scanMeta.filesTimedOut) || 0 });
|
|
11233
|
+
_tel.set('files.scanned', (_scanMeta && _scanMeta.filesScanned) || 0); _tel.set('files.skipped', (_scanMeta && _scanMeta.filesSkipped) || 0);
|
|
11234
|
+
return{telemetry:_tel.snapshot(),entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,linesScanned:Object.values(fc).reduce((_n,_c)=>_n+(typeof _c==='string'?_c.split("\n").length:0),0),crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,depFileContents,suppressions:_getSuppressions(),...(_stageEvidenceOn()?{stageEvidence:{dedupe:[..._stageEvidence.dedupe],guard:[..._stageEvidence.guard]}}:{}),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,detectorErrors:_detectorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,privacyIrBacked:_privacyIrBacked,privacyTaxonomyVersion:_privacyTaxonomyVersion,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary,scanHealth:_scanHealth,coverageLedger:_coverageLedger,lineageGraph:_lineageGraph,lineageStatus:_lineageStatus,languageBridges:_languageBridges,languageBom:_languageBom,aiAssistance:_aiAssistance};}
|
|
11167
11235
|
|
|
11168
11236
|
// Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
|
|
11169
11237
|
// Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
|