@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 +14 -0
- package/bin/agentic-security.js +1 -0
- package/dist/2923.index.js +17 -9
- package/dist/9382.index.js +23 -2
- package/dist/9579.index.js +10 -3
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{2a8456e0800aeee871b6.json → dad58d1113a12c23a142.json} +3 -2
- package/package.json +3 -2
- package/src/context-intelligence/controls.js +15 -0
- package/src/context-intelligence/reachability.js +8 -2
- package/src/discovery/hunter.js +6 -2
- package/src/discovery/index.js +5 -2
- package/src/engine.js +12 -0
- package/src/language/haskell-disclosure.js +13 -1
- package/src/lineage/observation-adapters.js +17 -9
- package/src/pipeline/scan-health.js +1 -0
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
|
|
package/bin/agentic-security.js
CHANGED
|
@@ -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'),
|
package/dist/2923.index.js
CHANGED
|
@@ -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;
|
|
447
|
-
*
|
|
448
|
-
*
|
|
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
|
-
|
|
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'`,
|
|
524
|
-
* decision
|
|
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
|
-
|
|
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;
|
package/dist/9382.index.js
CHANGED
|
@@ -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
|
-
|
|
3478
|
-
|
|
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'; }
|
package/dist/9579.index.js
CHANGED
|
@@ -271,8 +271,12 @@ async function runHunter(focusArea, lens, ctx = {}, opts = {}) {
|
|
|
271
271
|
}
|
|
272
272
|
|
|
273
273
|
const candidates = candidatesFromParsed(parsed, focusArea, lens);
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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);
|