@clear-capabilities/agentic-security-scanner 0.183.1 → 0.184.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 CHANGED
@@ -9,6 +9,20 @@
9
9
  > make the history less accurate, not more.
10
10
 
11
11
 
12
+ ## 0.184.0 - Runtime corroboration reaches GA on real exports, dependency and taint findings get contracts, and a scan says when it could not consult advisories
13
+
14
+ Runtime corroboration is the first Observe capability to earn GA. The other promotions of the programme's remaining items are contracts and corrections, not new maturity labels, and the ones that stay held say why.
15
+
16
+ - **Runtime corroboration is GA.** A hosted job, `telemetry-capture` (now a blocking check), runs the real OpenTelemetry JavaScript SDK with its OTLP exporter, Envoy 1.31.2, nginx 1.27.5 and a kind cluster with the Kubernetes audit log on, and records the files they write, untouched, with a manifest of tool, version, command and digest. The adapters are tested against those files: 3 spans, 6 Envoy lines, 4 nginx lines and 79 audit events parse with no error, every record passes the observation contract, and nothing that could carry a payload survives (trace ids, user names, source addresses, agents, paths). A reference page freezes the adapters, the runtime status values, the status report and the six edge states.
17
+ - **Two false security decisions found by the real exports are fixed.** The Envoy adapter read any response flag as a denial, so an upstream failure or a missing route was reported as `denied`; only an authorization-service denial is one now. The Kubernetes adapter read any status of 400 or more as a denial; on the real audit log (2 forbidden, 4 not found, 1 conflict) it would have reported 7 denials instead of 2. Now only 401 and 403 are.
18
+ - **A scan now says when it could not consult advisory data.** An offline scan with nothing cached, or an advisory service that did not answer, used to report `complete` with no vulnerable dependencies. It now reports `scanHealth.freshness.advisories` (the source, how many dependencies were checked and how many were not), becomes `partial`, and says an empty result is not evidence of safety. A dependency finding contract page and test freeze the finding's fields, the reachability tiers and this rule.
19
+ - **Taint findings have a contract.** The core fields of a data-flow finding, the proof and falsification verdicts and the hop fields are frozen on a reference page with a test that deep-scans fixtures. Writing it found a readable-label defect: the last hop of an interprocedural chain read `sink: [object Object]`; it now names the callee.
20
+ - **The model capabilities have frozen contracts, not GA.** Model-assisted validation, agentic discovery (hunt) and local model support each have a reference page and a compatibility test, and a recorded-reply replay harness plays eleven written model replies through the real validator and a set through the real hunt pipeline. Writing them found two defects, both fixed: a hunter reply whose candidates had no file and line was discarded without a word (it is now counted in the coverage reasons), and `--forget-refuted`, which the tool's own output tells you to use, was not read by the command. They stay Preview because the only model a hosted runner can run completes none of their work, so there is no precision to publish.
21
+ - **All ten compliance frameworks have a per-control coverage map.** The five that had none are generated from the framework files, a summary counts all ten, and the doc-drift gate fails when a map is stale or missing or the README count is wrong.
22
+ - **The deterministic context analysis sees Flask ORM operations and judges ownership at the first write.** On the application-shaped benchmark Flask went from 6 of 10 routes unresolved to 0 and Express precision from 82% to 100% (12 of 12), recall 100%. The cases are synthetic and author-labelled, so these are not accuracy on real applications; 2 routes behind a mounted router are still unresolved.
23
+ - **The finding provenance guide's limits were stale and are corrected**: the labelled corpus passes 13 of 13 and renames are followed. Coverage (83.8% of eligible findings against a 95% target) and cold-cache overhead (24 times on a 15-finding fixture, about 70 milliseconds of git work per file) are stated with their measured numbers.
24
+ - **Held, with the reasons on the promotion record:** the three model capabilities, Haskell and Nix (no independent second holdout), and the four Wave 1 Preview capabilities (Code Boundaries needs a lineage-only source catalog and field-identity changes; provenance cannot replay whole-project analyses).
25
+
12
26
  ## 0.183.1 - Exploit proof, the proof gate on fixes and source-to-artifact provenance reach GA, measured against the real tools
13
27
 
14
28
 
@@ -2743,6 +2743,7 @@ async function cmdHunt(args) {
2743
2743
  maxCostUsd: numFlag('max-cost-usd'),
2744
2744
  // PRD C4 — persist and consult cross-run memory.
2745
2745
  scanRoot: args.flags['no-memory'] ? undefined : scanRoot,
2746
+ forgetRefuted: !!args.flags['forget-refuted'],
2746
2747
  // PRD C2 — a comma-separated endpoint list turns on consensus.
2747
2748
  endpoints: args.flags.endpoints,
2748
2749
  costPerCallUsd: numFlag('cost-per-call-usd'),
@@ -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
@@ -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);