@clear-capabilities/agentic-security-scanner 0.182.0 → 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 +28 -0
- package/bin/agentic-security.js +12 -2
- package/dist/2923.index.js +17 -9
- package/dist/2946.index.js +6 -3
- package/dist/9382.index.js +23 -2
- package/dist/9579.index.js +10 -3
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{500bf9235f367cb445c0.json → dad58d1113a12c23a142.json} +3 -2
- package/package.json +3 -2
- package/src/artifact/cosign.js +6 -3
- 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/src/posture/proof-fabric/container.js +4 -1
- package/src/posture/proof-fabric/runner.js +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,34 @@
|
|
|
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
|
+
|
|
26
|
+
## 0.183.1 - Exploit proof, the proof gate on fixes and source-to-artifact provenance reach GA, measured against the real tools
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
**Release note.** A 0.183.0 tag was created and its hosted release gate failed before anything was published, so these changes ship as 0.183.1. The failure was a contract test that depended on warm local advisory data; it now seeds its own. The container probe also waits for a busy daemon and retries once instead of reading one slow answer as no daemon.
|
|
30
|
+
|
|
31
|
+
Each promotion is held to the GA bar and rests on a hosted CI job that runs the real tool and fails, rather than skips, when the tool is missing. Those jobs are now blocking checks, so a release cannot go out on a commit where they are red.
|
|
32
|
+
|
|
33
|
+
- **Execution-backed exploit proof is GA.** Measured on three hosts and committed: macOS 39 of 39 reproductions; Linux under local confinement 36 of 36 (hosted `proof-linux`); Linux in a real container 36 of 36 (hosted `proof-container`, docker 28.0.4). Each has 0 false confirmations and 3 of 3 hanging reproducers represented as degraded. Generation covers command injection, path-writing traversal and request forgery; the contract says so.
|
|
34
|
+
- **The proof gate on applying a fix is GA, and fails closed.** `fix --apply --require-proof` applied 0 of 15 unsafe fixes and 1 of 8 safe fixes on the fix-verification corpus (7 refused for want of a reproducer). The new bench matches its baseline on both Linux jobs.
|
|
35
|
+
- **Source-to-artifact provenance is GA.** The `artifact-real` job builds images with the real container engine, pushes to a real registry and signs with cosign: archive, engine and registry inputs agree (10 advisories), a signed image verifies, the same image with another key is `invalid`, an unsigned image is `missing`, and a source patched against a still-vulnerable image is flagged while an equal source is not.
|
|
36
|
+
- **The first real-tool runs found real defects, now fixed.** Java proofs could not start in the container (the runner replaced the image's `PATH`; 30 of 36 reproductions until fixed). The mismatch detector read a source scan limited to SCA as having no advisories, so a source on the same vulnerable version looked fixed; a source scan with no dependency data is now refused. cosign's wrong-key message was classified `missing` instead of `invalid`. A key-signed image from a registry with no transparency log could not be verified; `image verify --cosign --ignore-tlog` is an explicit opt-in and the result says the log was not checked.
|
|
37
|
+
- **The artifact guide's limits were stale and are rewritten** (it said no engine input, no registry input and no signing tool was exercised).
|
|
38
|
+
- **Held, with the measured reason on the promotion record:** local model support, model-assisted validation and hunt (a real 2B model on the hosted runner passed every contract check but completed 0 of 5 Eval Lab attempts and hunted 0 of 3 areas), runtime corroboration (no recorded real telemetry packs) and Haskell/Nix (no independent second holdout).
|
|
39
|
+
|
|
12
40
|
## 0.182.0 - Five Labs capabilities freeze their interfaces and move to Preview, and the strategic metrics become product numbers
|
|
13
41
|
|
|
14
42
|
Preview is not GA. Each move below gives a capability a reference page that lists every field, flag and exit code, and a compatibility test that runs the real commands and fails when the output gains or loses a field the page does not list.
|
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'),
|
|
@@ -6146,7 +6147,7 @@ async function cmdImageVerify(args) {
|
|
|
6146
6147
|
const { verifyWithCosign } = await import('../src/artifact/cosign.js');
|
|
6147
6148
|
const repo = typeof args.flags.repository === 'string' ? args.flags.repository : (resolved.record.source?.kind === 'registry' ? `${resolved.record.source.registry}/${resolved.record.source.repository}` : null);
|
|
6148
6149
|
signatureResult = repo
|
|
6149
|
-
? await verifyWithCosign({ repository: repo, digest: inventory.digest, publicKeyPem, certificateIdentity: args.flags['certificate-identity'], certificateOidcIssuer: args.flags['certificate-oidc-issuer'] })
|
|
6150
|
+
? await verifyWithCosign({ repository: repo, digest: inventory.digest, publicKeyPem, ignoreTlog: !!args.flags['ignore-tlog'], certificateIdentity: args.flags['certificate-identity'], certificateOidcIssuer: args.flags['certificate-oidc-issuer'] })
|
|
6150
6151
|
: { state: 'unavailable', via: 'cosign', reason: 'cosign verifies against a registry: give --repository <registry/repo>, or scan with --registry so the repository is known' };
|
|
6151
6152
|
}
|
|
6152
6153
|
const attestationResult = verifyProvenanceAttestation({ digest: inventory.digest, envelope: attestation, publicKeyPem });
|
|
@@ -6194,8 +6195,17 @@ async function cmdProvenanceTrace(args) {
|
|
|
6194
6195
|
}
|
|
6195
6196
|
const sourceScan = JSON.parse(fs.readFileSync(lastScanPath, 'utf8'));
|
|
6196
6197
|
const { detectSourceArtifactMismatch } = await import('../src/artifact/mismatch.js');
|
|
6198
|
+
// A full scan keeps its dependency findings in `supplyChain`; a scan limited with `--only sca` keeps them in `findings`. Reading only the first
|
|
6199
|
+
// made a source that carried the same advisories look fixed, which is the one reading this comparison must never produce, so both are read
|
|
6200
|
+
// and a source scan with no dependency data at all is refused instead of treated as clean.
|
|
6201
|
+
const sourceVulns = (sourceScan.supplyChain && sourceScan.supplyChain.length) ? sourceScan.supplyChain : (sourceScan.findings || []).filter((x) => x && x.type === 'vulnerable_dep');
|
|
6202
|
+
const sourceHasData = Array.isArray(sourceScan.supplyChain) || sourceVulns.length > 0 || !!(sourceScan.components && sourceScan.components.length);
|
|
6203
|
+
if (!sourceHasData) {
|
|
6204
|
+
process.stderr.write(`agentic-security provenance trace: --source "${args.flags.source}" carries no dependency data (no components and no dependency findings), so nothing can be compared; run "scan" there with SCA enabled.\n`);
|
|
6205
|
+
return 1;
|
|
6206
|
+
}
|
|
6197
6207
|
mismatch = detectSourceArtifactMismatch({
|
|
6198
|
-
sourceVulnerabilities:
|
|
6208
|
+
sourceVulnerabilities: sourceVulns,
|
|
6199
6209
|
artifactVulnerabilities: vulnerabilities,
|
|
6200
6210
|
artifactComponents: [...inventory.osPackages, ...inventory.appPackages],
|
|
6201
6211
|
});
|
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/2946.index.js
CHANGED
|
@@ -43,7 +43,7 @@ function findTool(name, pathEnv = process.env.PATH || '') {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/** @param {{repository: string, digest: string, publicKeyPem?: string, certificateIdentity?: string, certificateOidcIssuer?: string}} o */
|
|
46
|
-
async function verifyWithCosign({ repository, digest, publicKeyPem = null, certificateIdentity = null, certificateOidcIssuer = null, timeoutMs = 60_000, signal, pathEnv = process.env.PATH } = {}) {
|
|
46
|
+
async function verifyWithCosign({ repository, digest, publicKeyPem = null, certificateIdentity = null, certificateOidcIssuer = null, ignoreTlog = false, timeoutMs = 60_000, signal, pathEnv = process.env.PATH } = {}) {
|
|
47
47
|
const na = (reason) => ({ state: 'unavailable', via: 'cosign', reason });
|
|
48
48
|
if (!DIGEST_RE.test(String(digest))) return na('there is no sha256 image digest to verify a signature against');
|
|
49
49
|
if (!REPO_RE.test(String(repository ?? '')) || String(repository).includes('..') || String(repository).startsWith('-')) return na('the repository name is not valid');
|
|
@@ -56,6 +56,9 @@ async function verifyWithCosign({ repository, digest, publicKeyPem = null, certi
|
|
|
56
56
|
try {
|
|
57
57
|
const args = ['verify'];
|
|
58
58
|
if (publicKeyPem) { const kf = node_path__WEBPACK_IMPORTED_MODULE_2__.join(dir, 'key.pub'); node_fs__WEBPACK_IMPORTED_MODULE_0__.writeFileSync(kf, publicKeyPem, { mode: 0o600 }); args.push('--key', kf); } else args.push('--certificate-identity', certificateIdentity, '--certificate-oidc-issuer', certificateOidcIssuer);
|
|
59
|
+
// cosign requires a transparency-log entry for a key-signed image unless told otherwise. A private registry or an air-gapped build has none, so
|
|
60
|
+
// this is an explicit opt-in, never the default, and a result that skipped the log says so. Keyless verification always needs the log.
|
|
61
|
+
if (ignoreTlog && publicKeyPem) args.push('--insecure-ignore-tlog=true');
|
|
59
62
|
args.push(`${repository}@${digest}`);
|
|
60
63
|
const r = await (0,_sandbox_supervise_js__WEBPACK_IMPORTED_MODULE_3__/* .superviseSpawn */ .u)(bin, args, { cwd: dir, env: (0,_sandbox_trust_domains_js__WEBPACK_IMPORTED_MODULE_4__/* .scrubEnv */ .LK)(process.env), timeoutMs, signal, maxOutputBytes: 2 * 1024 * 1024 });
|
|
61
64
|
if (r.spawnError) return na(`could not start cosign: ${r.spawnError}`);
|
|
@@ -66,11 +69,11 @@ async function verifyWithCosign({ repository, digest, publicKeyPem = null, certi
|
|
|
66
69
|
let payloads = [];
|
|
67
70
|
try { const j = JSON.parse(r.stdout); payloads = Array.isArray(j) ? j : [j]; } catch { /* handled below */ }
|
|
68
71
|
const matching = payloads.filter((p) => p?.critical?.image?.['docker-manifest-digest'] === digest);
|
|
69
|
-
if (matching.length) return { state: 'verified', via: 'cosign', mode: keyless ? 'keyless' : 'key', signatures: matching.length, identity: keyless ? certificateIdentity : null, reason: null };
|
|
72
|
+
if (matching.length) return { state: 'verified', via: 'cosign', mode: keyless ? 'keyless' : 'key', signatures: matching.length, identity: keyless ? certificateIdentity : null, reason: null, ...(ignoreTlog && !keyless ? { transparencyLog: 'not-checked' } : {}) };
|
|
70
73
|
return { state: 'invalid', via: 'cosign', reason: 'cosign reported success but none of the verified signatures names this image digest' };
|
|
71
74
|
}
|
|
72
|
-
if (/no signatures found|no matching signatures|no signatures associated|manifest unknown/.test(err)) return { state: 'missing', via: 'cosign', reason: 'no signature exists for this digest' };
|
|
73
75
|
if (/invalid signature|signature verification failed|crypto\/ecdsa|verification failed|certificate identity|none of the expected identities/.test(err)) return { state: 'invalid', via: 'cosign', reason: 'cosign rejected the signature or the identity' };
|
|
76
|
+
if (/no signatures found|no matching signatures|no signatures associated|manifest unknown/.test(err)) return { state: 'missing', via: 'cosign', reason: 'no signature exists for this digest' };
|
|
74
77
|
return na(`cosign could not complete the check (exit ${r.exitCode}): ${String(r.stderr).trim().split('\n').pop()?.slice(0, 160) ?? ''}`);
|
|
75
78
|
} finally { try { node_fs__WEBPACK_IMPORTED_MODULE_0__.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
76
79
|
}
|
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);
|