@clear-capabilities/agentic-security-scanner 0.183.1 → 0.185.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.
Files changed (85) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/agentic-security.js +88 -0
  3. package/dist/{2a8456e0800aeee871b6.json → 0b301d45b231f144a1b4.json} +27 -9
  4. package/dist/1128.index.js +908 -0
  5. package/dist/1336.index.js +261 -112
  6. package/dist/1665.index.js +828 -0
  7. package/dist/2923.index.js +17 -9
  8. package/dist/9382.index.js +23 -2
  9. package/dist/9579.index.js +11 -4
  10. package/dist/agentic-security.mjs +15 -15
  11. package/dist/agentic-security.mjs.sha256 +1 -1
  12. package/dist/frontend/src/shell.js +12 -2
  13. package/package.json +27 -9
  14. package/src/context-intelligence/controls.js +15 -0
  15. package/src/context-intelligence/reachability.js +8 -2
  16. package/src/dataflow/CLAUDE.md +43 -1
  17. package/src/dataflow/backward.js +42 -7
  18. package/src/dataflow/cross-service-taint.js +83 -1
  19. package/src/dataflow/demand-query.js +929 -0
  20. package/src/dataflow/dep-fingerprint.js +718 -0
  21. package/src/dataflow/engine.js +48 -6
  22. package/src/dataflow/ifds-precise.js +22 -0
  23. package/src/dataflow/incremental-replay.js +118 -0
  24. package/src/dataflow/incremental-session.js +257 -0
  25. package/src/dataflow/incremental.js +237 -35
  26. package/src/dataflow/index.js +25 -94
  27. package/src/dataflow/summaries.js +72 -6
  28. package/src/dataflow/tabulation.js +80 -24
  29. package/src/discovery/hunter.js +6 -2
  30. package/src/discovery/index.js +5 -2
  31. package/src/engine.js +63 -3
  32. package/src/graph/CLAUDE.md +31 -0
  33. package/src/graph/adapters/artifact.js +81 -0
  34. package/src/graph/adapters/crossrepo.js +75 -0
  35. package/src/graph/adapters/dataflow.js +76 -0
  36. package/src/graph/adapters/provenance.js +92 -0
  37. package/src/graph/adapters/semantic.js +89 -0
  38. package/src/graph/crosswalk-builders.js +152 -0
  39. package/src/graph/crosswalk.js +146 -0
  40. package/src/graph/index.js +15 -0
  41. package/src/graph/observation.js +57 -0
  42. package/src/graph/refs.js +105 -0
  43. package/src/graph/registry.js +129 -0
  44. package/src/graph/relations.js +81 -0
  45. package/src/graph/scc.js +241 -0
  46. package/src/graph/view.js +254 -0
  47. package/src/language/haskell-disclosure.js +13 -1
  48. package/src/lineage/CLAUDE.md +71 -8
  49. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  50. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  51. package/src/lineage/boundary-contract.js +273 -0
  52. package/src/lineage/completion.js +205 -0
  53. package/src/lineage/continuation.js +140 -0
  54. package/src/lineage/control-credit.js +313 -0
  55. package/src/lineage/correlation.js +199 -0
  56. package/src/lineage/coverage.js +8 -1
  57. package/src/lineage/cross-repo-link.js +22 -0
  58. package/src/lineage/cut-planner.js +548 -0
  59. package/src/lineage/driver.js +219 -66
  60. package/src/lineage/engine.js +166 -21
  61. package/src/lineage/export-json.js +5 -0
  62. package/src/lineage/graph-builder.js +84 -7
  63. package/src/lineage/haskell-view.js +59 -0
  64. package/src/lineage/index.js +3 -0
  65. package/src/lineage/nix-view.js +35 -0
  66. package/src/lineage/observation-adapters.js +17 -9
  67. package/src/lineage/owned-build.js +77 -0
  68. package/src/lineage/owned-worker-entry.js +22 -0
  69. package/src/lineage/owned-worker.js +95 -0
  70. package/src/lineage/path-evidence.js +123 -0
  71. package/src/lineage/path-predicates.js +529 -0
  72. package/src/lineage/path-query.js +189 -20
  73. package/src/lineage/path-store.js +191 -17
  74. package/src/lineage/scc-solver.js +186 -0
  75. package/src/lineage/smt-orchestrator.js +138 -0
  76. package/src/lineage/source-seeding.js +36 -2
  77. package/src/lineage/summaries.js +66 -4
  78. package/src/lineage/value-versions.js +130 -0
  79. package/src/lineage/witness-selection.js +95 -0
  80. package/src/pipeline/scan-health.js +19 -0
  81. package/src/posture/CLAUDE.md +13 -0
  82. package/src/posture/evidence-bundle.js +1 -1
  83. package/src/posture/witness-bundle.js +573 -0
  84. package/src/posture/witness-runtime.js +276 -0
  85. package/src/report/index.js +2 -0
@@ -443,9 +443,10 @@ function parseOtelJsonObservations(text, context) {
443
443
  * `destination.service`), `protocol` (normalized — Envoy writes
444
444
  * `"HTTP/1.1"`, which contains `/` and is not an approved identifier
445
445
  * shape; lowercased and `/` stripped, e.g. `"http1.1"`), and
446
- * `response_flags` (`'-'`/empty means no flag, i.e. allowed; a non-empty
447
- * flags string, e.g. `"UAEX"` for unauthorized, is treated as `'denied'`
448
- * via `network.securityDecision`). `start_time`/`end_time` (or
446
+ * `response_flags` (`'-'`/empty means no flag, i.e. allowed; `"UAEX"`, the
447
+ * external authorization service denying the request, is `'denied'` via
448
+ * `network.securityDecision`; any other flag, such as an upstream failure or
449
+ * a missing route, is an operational outcome and carries no decision). `start_time`/`end_time` (or
449
450
  * `start_time` alone, with `duration` in milliseconds added) feed
450
451
  * `firstObservedAt`/`lastObservedAt`. Everything else — client address,
451
452
  * request path/query, user agent, request id, response code's own exact
@@ -488,7 +489,13 @@ function parseEnvoyAccessLogObservations(text, context) {
488
489
  if (cluster) attributes['destination.service'] = cluster;
489
490
  if (protocol) attributes['network.protocol'] = protocol;
490
491
  if (typeof record.response_flags === 'string') {
491
- attributes['network.securityDecision'] = record.response_flags.trim().length === 0 || record.response_flags.trim() === '-' ? 'allowed' : 'denied';
492
+ // Only a flag that records an authorization decision is a security decision. UAEX is the external authorization service
493
+ // denying the request. Every other flag (UF upstream connection failure, NR no route, UT timeout, DC client disconnect) is
494
+ // an operational outcome, and calling it "denied" would invent a denial, so those make no claim either way.
495
+ const flags = record.response_flags.trim();
496
+ const tokens = flags.split(',').map((t) => t.trim()).filter(Boolean);
497
+ if (flags.length === 0 || flags === '-') attributes['network.securityDecision'] = 'allowed';
498
+ else if (tokens.includes('UAEX')) attributes['network.securityDecision'] = 'denied';
492
499
  }
493
500
 
494
501
  if (Object.keys(attributes).length === 0) continue; // nothing safe/present to report for this line
@@ -520,10 +527,8 @@ function parseEnvoyAccessLogObservations(text, context) {
520
527
  * `destination.service` — never `objectRef.name`, which is frequently a
521
528
  * user-chosen, potentially sensitive resource name) and
522
529
  * `responseStatus.code` (mapped to `network.securityDecision`: `< 400`
523
- * is `'allowed'`, `>= 400` is `'denied'` — a client error is treated as a
524
- * decision here because the audit log's own admission/authorization
525
- * failures surface as 401/403/409, which is exactly the "security
526
- * decision" fact this adapter exists to carry). `requestReceivedTimestamp`/
530
+ * is `'allowed'`, `401`/`403` is `'denied'`, anything else carries no
531
+ * decision: a not-found or a conflict is not an authorization outcome). `requestReceivedTimestamp`/
527
532
  * `stageTimestamp` feed the observed-at window. **`user.username` and
528
533
  * every request/response body field are deliberately never read** — a
529
534
  * K8s audit event's `user` block is exactly the "authenticated identity"
@@ -565,7 +570,10 @@ function parseK8sAuditLogObservations(text, context) {
565
570
  if (namespace && resource) attributes['destination.service'] = `${namespace}:${resource}`;
566
571
  else if (resource) attributes['destination.service'] = resource;
567
572
  if (typeof code === 'number' && Number.isFinite(code)) {
568
- attributes['network.securityDecision'] = code < 400 ? 'allowed' : 'denied';
573
+ // 401 and 403 are the authentication and authorization outcomes. Any other client or server error (404 not found, 409 conflict,
574
+ // 422 invalid, 5xx) is an operational outcome and makes no claim, the same rule as the proxy adapters.
575
+ if (code < 400) attributes['network.securityDecision'] = 'allowed';
576
+ else if (code === 401 || code === 403) attributes['network.securityDecision'] = 'denied';
569
577
  }
570
578
 
571
579
  if (Object.keys(attributes).length === 0) continue;
@@ -1442,6 +1442,9 @@ const AUTH_NAME = /(?:^|[.:])(?:(?:require|ensure|check|assert|verify|validate|e
1442
1442
 
1443
1443
  const SENSITIVE_NAME = /(?:^|[.:])(?:query|queryOne|queryRow|exec|execute|executeQuery|raw|rawQuery|prepare|find|findAll|findOne|findById|findByPk|findUnique|findFirst|findMany|get|getById|getBy|select|selectList|selectFirst|insert|insertOne|insertMany|insert_|update|updateOne|updateMany|upsert|delete|deleteOne|deleteMany|remove|destroy|save|create|createMany|aggregate|runDB|writeFile|writeFileSync|appendFile|unlink|rm|rmdir|spawn|spawnSync|execSync|execFile|system|popen|fetch|request|post|put|patch)$/i;
1444
1444
  const DB_RECEIVER = /(?:^|\b)(?:db|database|conn|connection|pool|client|cursor|session|repo|repository|dao|model|models|collection|table|prisma|knex|sequelize|orm|store|objects|manager|em|entityManager|mongo|redis|cache|conn|stmt|statement|fs|child_process|cp|axios|http|https|requests|httpx|got|superagent|Database|Persist|Yesod|System)\b/;
1445
+ // Flask-SQLAlchemy `Model.query...`, SQLAlchemy `session.query/add/delete/merge/execute/get`, and the query-builder methods that end a chain.
1446
+ const PY_ORM = /(?:\b[A-Z]\w*\.query\b|\b(?:db\.)?session\.(?:query|add|add_all|delete|merge|execute|get|bulk_save_objects)\b|\.(?:filter_by|get_or_404|first_or_404|one_or_404)\()/;
1447
+ const PY_ORM_WRITE = /\.(?:delete|update|bulk_update_mappings|bulk_insert_mappings)\(|\b(?:db\.)?session\.(?:add|add_all|delete|merge|bulk_save_objects)\b/;
1445
1448
  const SQL_TEXT = /\b(?:select\s[\s\S]{1,120}\sfrom|insert\s+into|update\s+\w+\s+set|delete\s+from)\b/i;
1446
1449
  const HS_SENSITIVE = /(?:^|\.)(?:runDB|selectList|selectFirst|getBy|getJust|insert_?|insertEntity|update|updateWhere|delete|deleteWhere|replace|upsert|query|query_|execute|execute_|readFile|writeFile|callProcess|readProcess)$|^(?:Database\.|System\.Process\.|System\.Directory\.)/;
1447
1450
 
@@ -1601,6 +1604,18 @@ function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
1601
1604
  }
1602
1605
  }
1603
1606
  }
1607
+ // Python ORM calls. `Model.query.filter_by(...).delete()` and `db.session.add(x)` name no database receiver the call-name rule
1608
+ // recognises, and the chained `.delete()` is not a call on one, so the data operation was invisible and a route that
1609
+ // deletes a row read as touching nothing sensitive. A write is a chained mutation or a session write; anything else is a read.
1610
+ if (!haskell && PY_ORM.test(raw)) {
1611
+ const orm = PY_ORM_WRITE.test(raw) ? 'write' : 'read';
1612
+ push({ line: ln, category: 'sensitive', kind: `data-${orm}`, subkind: orm, evidence: 'observed', basis: 'orm-call', rejectionSeen: null, text: clip(raw) });
1613
+ const stmt = statementAt(lines, ln);
1614
+ const pr = ownerBinding(stmt, extraPrincipal);
1615
+ if (pr) {
1616
+ push({ line: ln, category: 'control', kind: pr.tenant ? 'tenant-scope' : 'ownership', subkind: null, evidence: pr.level === 'qualified' ? 'observed' : 'inferred', basis: pr.level === 'qualified' ? 'query-filter' : 'query-filter-unqualified', rejectionSeen: null, text: clip(raw) });
1617
+ }
1618
+ }
1604
1619
  if (SQL_TEXT.test(raw)) {
1605
1620
  const verb = /\b(?:insert|update|delete)\b/i.test(raw) ? 'write' : 'read';
1606
1621
  push({ line: ln, category: 'sensitive', kind: `data-${verb}`, subkind: verb, evidence: 'observed', basis: 'sql-text', rejectionSeen: null, text: clip(raw) });
@@ -3474,8 +3489,14 @@ function evaluateProtection({ facts, unknowns, omittedWithControls = false, capp
3474
3489
  const ctl = facts.filter(f => f.category === 'control' && kinds.includes(f.kind));
3475
3490
  // A control whose answer is visibly thrown away (a predicate called as a bare statement) guards nothing.
3476
3491
  const effective = (f) => f.rejectionSeen !== false;
3477
- const before = ctl.filter(f => effective(f) && (firstSens === null || f.effLine < firstSens || (f.basis === 'query-filter' && f.effLine <= firstSens)));
3478
- const after = ctl.filter(f => firstSens !== null && f.effLine > firstSens);
3492
+ // Ownership is the one dimension where reading before checking is the ordinary safe shape: load the record by id, refuse unless the
3493
+ // principal owns it, and only then act on it. What an unchecked read can do is bounded by the check that follows before anything is
3494
+ // written, so for ownership and tenant scope the line that must be preceded is the first WRITE. With no write in view the first
3495
+ // sensitive operation of any kind still applies, so a read returned without a check is not excused.
3496
+ const writes = sens.filter(f => f.subkind === 'write' || f.kind === 'filesystem' || f.kind === 'process');
3497
+ const gate = dim === 'ownership' && writes.length ? Math.min(...writes.map(f => f.effLine)) : firstSens;
3498
+ const before = ctl.filter(f => effective(f) && (gate === null || f.effLine < gate || (f.basis === 'query-filter' && f.effLine <= gate)));
3499
+ const after = ctl.filter(f => gate !== null && f.effLine > gate);
3479
3500
  let state; let why;
3480
3501
  if (before.length) { state = 'present'; why = 'a control precedes the first sensitive operation'; }
3481
3502
  else if (after.length) { state = 'evaluated_and_ineffective'; why = 'every control of this kind runs after the first sensitive operation'; }
@@ -271,8 +271,12 @@ async function runHunter(focusArea, lens, ctx = {}, opts = {}) {
271
271
  }
272
272
 
273
273
  const candidates = candidatesFromParsed(parsed, focusArea, lens);
274
- appendEntry(transcript, { phase: 'result', candidateCount: candidates.length });
275
- return { ...base, candidates, degraded: false, reason: null };
274
+ // A proposed candidate with no file and line has nowhere to point and is dropped; say how many, so a reply full of them
275
+ // does not read as a reply that found nothing.
276
+ const proposed = Array.isArray(parsed?.candidates) ? parsed.candidates.length : 0;
277
+ const discarded = proposed - candidates.length;
278
+ appendEntry(transcript, { phase: 'result', candidateCount: candidates.length, discarded });
279
+ return { ...base, candidates, degraded: false, reason: null, discarded };
276
280
  }
277
281
 
278
282
  // EXTERNAL MODULE: ./src/discovery/confirm.js
@@ -733,7 +737,7 @@ function makeTaintProbe(perFileIR, callGraph) {
733
737
 
734
738
  async function runDeepAnalysisSafe(perFileIR, callGraph) {
735
739
  try {
736
- const { runDeepAnalysis } = await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 72555));
740
+ const { runDeepAnalysis } = await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 18561));
737
741
  return runDeepAnalysis(perFileIR, callGraph, {});
738
742
  } catch {
739
743
  return null;
@@ -835,7 +839,9 @@ async function runDiscovery(ctx = {}, opts = {}) {
835
839
  const budget = makeBudget(opts);
836
840
  // PRD C4 — what previous runs already judged. scanRoot absent => no memory,
837
841
  // which is the correct default for a library call with nowhere to persist.
838
- const memory = opts.scanRoot ? loadMemory(opts.scanRoot) : null;
842
+ let memory = opts.scanRoot ? loadMemory(opts.scanRoot) : null;
843
+ // `--forget-refuted`: a model, a ruleset or the code changed, so what an earlier run refuted is worth examining again.
844
+ if (memory && opts.forgetRefuted) memory = forgetRefuted(memory);
839
845
  // Every LLM call in this pipeline goes through this one wrapped callback.
840
846
  const llmInvoke = budget.wrap(opts.llmInvoke);
841
847
 
@@ -870,6 +876,7 @@ async function runDiscovery(ctx = {}, opts = {}) {
870
876
  const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {}, scanRoot: opts.scanRoot }, { llmInvoke, scanRoot: opts.scanRoot });
871
877
  runs.push({ focusAreaId: run.focusAreaId, lens: run.lens, degraded: run.degraded, reason: run.reason, candidateCount: run.candidates.length });
872
878
  if (run.degraded && run.reason) reasons.push(`${area.label} × ${lens.key}: ${run.reason}`);
879
+ if (run.discarded > 0) reasons.push(`${area.label} × ${lens.key}: ${run.discarded} proposed candidate(s) had no usable file and line and were discarded`);
873
880
  if (run.degraded) areaDegradedCount += 1;
874
881
  else hunted.add(area.id);
875
882
  candidates = candidates.concat(run.candidates);