@clear-capabilities/agentic-security-scanner 0.195.22 → 0.195.24
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/dist/3596.index.js +1 -1
- package/dist/550.index.js +9 -8
- package/dist/5620.index.js +59 -10
- package/dist/5937.index.js +88 -16
- package/dist/7838.index.js +32 -5
- package/dist/9441.index.js +50 -7
- package/dist/9824.index.js +19 -13
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{c1e282247b06d51c8ffb.json → daefda0819bb18309b75.json} +3 -3
- package/package.json +3 -3
- package/src/discovery/detector-spec-synthesis.js +3 -3
- package/src/discovery/llm-invoke.js +3 -3
- package/src/egress/transport.js +148 -0
- package/src/lineage/CLAUDE.md +12 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +23 -0
- package/src/lineage/driver.js +19 -2
- package/src/lineage/engine.js +24 -1
- package/src/lineage/graph-builder.js +3 -1
- package/src/lineage/lineage-source-catalog.js +143 -0
- package/src/lineage/source-seeding.js +142 -16
- package/src/lineage/summaries.js +21 -5
- package/src/llm-validator/index.js +4 -2
- package/src/llm-validator/local-endpoint.js +4 -1
- package/src/llm-validator/ollama-provider.js +8 -6
- package/src/posture/adversary-agent.js +27 -10
- package/src/posture/custom-rules.js +13 -1
- package/src/posture/fleet/cli.js +2 -4
- package/src/posture/fleet/fleet-manifest.js +4 -1
- package/src/posture/fleet/job-runner.js +28 -4
- package/src/posture/fleet/org-health.js +23 -0
- package/src/posture/fleet/scan-cache.js +1 -1
- package/src/posture/flow-narration.js +10 -8
- package/src/posture/llm-redteam.js +3 -1
- package/src/posture/provenance/coordinator.js +8 -0
- package/src/posture/provenance/origin-resolver.js +17 -4
- package/src/posture/rule-compiler/cli.js +56 -8
- package/src/posture/rule-compiler/corpus.js +10 -2
- package/src/posture/rule-compiler/ir.js +10 -1
- package/src/posture/rule-compiler/promotion.js +12 -2
- package/src/posture/rule-compiler/regression.js +1 -1
- package/src/posture/rule-compiler/store.js +27 -7
- package/src/posture/strategic-metrics.js +50 -7
- package/src/sca/llm-function-extract.js +17 -13
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,32 @@
|
|
|
9
9
|
> make the history less accurate, not more.
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
## 0.195.24 - GA programme, release D: lineage sources the taint engine does not own, seeds that survive an alias and a call
|
|
13
|
+
|
|
14
|
+
GA-03.1 of the GA plan asked for the lineage corpus to be re-run on today's source, each failure classified, the source-category omissions closed without broadening the main taint catalog incorrectly, and aliasing and field identity fixed across calls. The corpus (`bench/data-lineage/`) now passes 24 of 24, all regression-tier, from 17 of 24 with seven capability-tier failures. Nothing moved a maturity: Code Boundaries stays Preview, because the real-code graph precision and recall of the frozen protocol (GA-02) are still unmeasured.
|
|
15
|
+
|
|
16
|
+
- **A lineage-only source catalog** (`scanner/src/lineage/lineage-source-catalog.js`). Four of the fourteen reachable source categories had no JavaScript entry in the taint catalog (`cli-argument`, `http-upload`, `storage-read`, `user-input`), so the four corpus fixtures naming them were written in Python syntax and could never mint a source node. Six JavaScript entries now serve them (`process.argv`, `req.file`, `req.files`, `fs.readFileSync`, bare `prompt()`, `readlineSync.question()`), consulted by `source-seeding.js` after the main matcher, for JavaScript and TypeScript files only. They live beside the taint catalog, not in it, the way the privacy sink catalog does: a file's contents and a console prompt are where personal data enters a program, which the lineage graph records, but treating them as attacker-controlled would turn every configuration read into a taint source. A test pins that the main matcher returns null for every shape, and the self-scan, mutation and layer-recall gates are unchanged.
|
|
17
|
+
- **Seeds after a call-shaped assignment.** A source that is a call (`const record = fs.readFileSync(p)`, `const data = res.json()`) has no access path, and until now was recorded as unseedable. It is seeded on the assignment target in the state leaving that node (`after-node` seeds, applied by the engine's worklist), so the value exists where the program puts it. A call-shaped source nested in another call's arguments is still unseedable, and still recorded.
|
|
18
|
+
- **Seeds across an alias.** `const body = req.body; body.card_number` seeded the container and minted an unclassified element named `body`. The planner now follows the alias within the function and mints the field seed on the original path, so the flow carries `card_number` as PCI. Chained aliases are followed for at most four rounds; a dynamic key is never seeded; a parameter is never an alias.
|
|
19
|
+
- **Seeds under every call context.** A field read in a helper and returned to the caller produced the right nodes and element and zero flows, because the caller resolved the helper under the state its arguments bound and the helper's own seed was absent from it. The call-summary resolver now joins the callee's own seeds onto that state: a source read inside a function is a fact about that function wherever it is called from. The cache rule of `DESIGN_GRAPH_BUILDER.md` section 3.6 stands, keyed by the joined state so nothing is ever stored under the empty-entry key for a seeded function; section 3.8 records the revision and the driver test pins both directions.
|
|
20
|
+
- **The runner's contract** gained `expectUnsupportedSink` (the `exec` fixture's process-kind node with a reason, AC-11) and reads an empty `dataClass` label as "match on the categories alone" (a positional argument, a file's contents). The user-input fixture was renamed `js-user-input-to-log-pii` once its element had a name to classify. Every fixture's notes record what changed and how it was measured.
|
|
21
|
+
- **Open, measured and pinned:** a sink inside a callee never sees an argument's identity, because a bare call statement is not resolved through the summary. Resolving it was tried and reverted: it consumes a summary context per call site and under a tight context cap degrades a later resolution, which moved seven pinned provenance measurements. `seeding-across-calls.test.js` pins the zero so the gap is measured, not assumed.
|
|
22
|
+
|
|
23
|
+
Gates, from the runs recorded for this entry: `test:lineage` 1570/1570 (one new file, `seeding-across-calls.test.js`, wired into the scope), `test:lifecycle` 491/491, `bench:data-lineage:check` 24/24, `bench:self-scan:check` no drift (683/106/21/0), `bench:mutation:check` every verdict correct, `bench:layer-recall:check` unchanged. The 0.195.23 ship stopped once in the checks phase: the hosted macOS runner never picked up `determinism-attest (macos-latest)` on three attempts during a GitHub Actions incident (every other job green); after GitHub's mitigation a resume passed it on the next attempt. Measured 0.195.23 `TIMINGS`: `preflight=4s push=7s pr=6s checks=77s merge=6s verify=6s tag=2s release=255s npm=756s total=1119s release-clock=269s` (the checks figure is the resumed run; the first attempt spent 3644 s waiting on the runner).
|
|
24
|
+
|
|
25
|
+
## 0.195.23 - GA programme, release C: one model transport, reviewed rule promotion, metrics with a scope, provenance that proves absence, fleet results that age
|
|
26
|
+
|
|
27
|
+
**Measured, 0.195.22:** `TIMINGS preflight=3s push=6s pr=4s checks=398s merge=5s verify=6s tag=2s release=161s npm=206s total=791s release-clock=174s`, shipped in 14 min 56 s on the second attempt (the first was stopped by the background gate's `ci-parity` check: a new deep-mode pin skipped under the hosted-CI switches until it set `DEEP_IN_CI` like the existing helper).
|
|
28
|
+
|
|
29
|
+
The next tranche of the GA plan that is code and tests: GA-06.3 (the model transport), GA-07.1 to 7.3 (the rule compiler's gates), GA-11.3 and 11.4 (strategic metrics), GA-05.2 and 5.4 (provenance parent boundaries) and GA-09.5 (fleet accounting). The lineage corpus was re-run on today's source for GA-03.1 and is still 17 of 24, with the same seven capability-tier failures the promotion record names (four source categories with no JavaScript catalog entry, an alias hop, an interprocedural return, an unsupported sink); that engine work is recorded as open, not done.
|
|
30
|
+
|
|
31
|
+
- **One transport for every model call.** `scanner/src/egress/transport.js` (`modelFetch`): no redirect is followed (a loopback endpoint answering 3xx to a remote host would have carried the prompt off the machine; eight call sites followed redirects by default), the reply is capped while it is read (every site read `res.json()` unbounded; 4 MiB by default, `AGENTIC_SECURITY_LLM_MAX_RESPONSE_BYTES`), every call has a deadline (four sites had none and could hold a scan open on a stalled connection), an optional process-wide wall budget (`AGENTIC_SECURITY_LLM_WALL_BUDGET_MS`), and proxy variables are documented as ignored. The validator, the Ollama provider, discovery, synthesis, the dependency function extractor, flow narration, the red-team sender, the adversary agent (its model call and its probes of the target) and the autopilot use it. The GA ledger check `egress-all-model-paths` now also fails on a model site that sends through a bare `fetch`, and a file that uses only the transport is still a candidate.
|
|
32
|
+
- **The `local` preset holds on every caller.** Flow narration, the dependency function extractor and the adversary agent read the raw endpoint variable and called it regardless of the preset; each resolves its provider as the validator does, so a non-loopback endpoint under `local` is refused there too, and a bare `OPENAI_API_KEY` is no longer attached to an arbitrary endpoint by the adversary agent. `0.0.0.0`, `[::]` and `http://0/` are refused as loopback (the unspecified address reaches a local interface by operating-system convention, not by its meaning); the contract page's table says so.
|
|
33
|
+
- **The rule compiler promotes by review.** `rules promote` needs `--reviewed-by <person>` (a model or agent name is not a reviewer) and records the reviewer and `--scope` in the ledger and the provenance. Promotion needs independent examples (`independentVulnerableCode`, `independentCleanCode`: outside the finding, never mutated; every vulnerable one detected, no safe one fired on), refuses a rule edited after its evaluation, an originating commit not in the repository's history, a hand-written rule file under the same id, and a promoted rule with the identical match. The lifecycle ledger is signed with the per-install key and never replaced by an empty one on a failed check; an unsigned pre-0.195.23 ledger is accepted once with `AGENTIC_SECURITY_RULES_UNSIGNED=1`. Every custom and compiled rule pattern now passes the SAST layer's superlinear-regex detector, not only the nested-quantifier heuristic (`(a|a)*b` was accepted). The CI templates run `rules regress` after the scan. Found on the way: the rule digest passed the top-level key list as the JSON replacer, which also filters nested objects, so `match.pattern` and every structural regex were digested as `{}`; the digest covers the whole rule now.
|
|
34
|
+
- **Strategic metrics state their scope.** The report carries `scope` (per-scan, the scan id and start time, the attempt window, and that it is not a trend), one status vocabulary on every metric (`measured`, `partial`, `unmeasured`; the learning rate was `not-measured`), and the remediation rate counts one attempt per finding (the latest; earlier ones reported as `repeated`), with `recorded`, `failed` and `window` beside the numerator and denominator. Five retries of one fix were five attempts before.
|
|
35
|
+
- **Provenance proves absence or says it cannot.** A parent commit whose replay failed counted as verified absence (`present: false` for any reason); it is now `partial` with reason `parent-not-inspectable`, and a `complete` result names its evidence of absence (`stableId-not-reproduced`, or `no-files-at-commit` for a file new in that commit).
|
|
36
|
+
- **Fleet results age, and a bad artifact is never green.** A cached repository result records when it was cached and is reused only inside the manifest's `cacheMaxAge` (7 days by default; a record with no time is never reused). An artifact that fails its hash check for an optional repository makes the organization degraded (it stayed green), the counts move from the state the repository had (a partial result's problem no longer decremented `complete`), and artifacts are written whole or not at all.
|
|
37
|
+
|
|
12
38
|
## 0.195.22 - GA programme, release B: every model path through the egress policy, one assurance decision on every surface, and unknown is not fresh
|
|
13
39
|
|
|
14
40
|
**Measured, 0.195.21:** `TIMINGS preflight=3s push=5s pr=4s checks=393s merge=5s verify=6s tag=2s release=223s npm=145s total=786s release-clock=236s`, shipped in 14 min 56 s on the third attempt (the first stopped at verify on the new expected-jobs rule because the check-runs query had never been paginated; the second stopped at the warm gate because the CodeQL summary check is not a workflow job). The npm check was re-run once with `--resume`.
|
package/dist/3596.index.js
CHANGED
|
@@ -10,7 +10,7 @@ export const modules = {
|
|
|
10
10
|
/* harmony export */ });
|
|
11
11
|
/* unused harmony export HS_SWEEP_VERSION */
|
|
12
12
|
/* harmony import */ var _dataflow_catalog_haskell_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(7225);
|
|
13
|
-
/* harmony import */ var _lineage_source_seeding_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(
|
|
13
|
+
/* harmony import */ var _lineage_source_seeding_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(95174);
|
|
14
14
|
// Root-cause sweep for Haskell (X-008). The generic sweep anchors on a `callee(args)` text shape, which Haskell's
|
|
15
15
|
// juxtaposition syntax never produces, so a confirmed Haskell finding swept nothing. This sweep works on the IR: for
|
|
16
16
|
// each confirmed finding it takes the RESOLVED sink callee from the model catalog and lists every other call of that
|
package/dist/550.index.js
CHANGED
|
@@ -287,8 +287,9 @@ function judgeCandidates(candidates, priorScan, triageFeedback) {
|
|
|
287
287
|
/* harmony export */ });
|
|
288
288
|
/* unused harmony exports defaultLlmInvoke, parseEndpoints, consensusOf, resolveLlmInvoke */
|
|
289
289
|
/* harmony import */ var _egress_policy_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(45712);
|
|
290
|
-
/* harmony import */ var
|
|
291
|
-
/* harmony import */ var
|
|
290
|
+
/* harmony import */ var _egress_transport_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(73785);
|
|
291
|
+
/* harmony import */ var _llm_validator_providers_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(38947);
|
|
292
|
+
/* harmony import */ var _llm_validator_ollama_provider_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(23837);
|
|
292
293
|
//
|
|
293
294
|
// Shared LLM endpoint caller. Both the hunter and the refutation panel need
|
|
294
295
|
// the same default endpoint caller when tests don't inject a mock. Two copies
|
|
@@ -300,6 +301,7 @@ function judgeCandidates(candidates, priorScan, triageFeedback) {
|
|
|
300
301
|
|
|
301
302
|
|
|
302
303
|
|
|
304
|
+
|
|
303
305
|
const DEFAULT_TIMEOUT_MS = 60000;
|
|
304
306
|
|
|
305
307
|
// agentic-security-ollama-offline-prd.md §32 — hunt is one of the highest-
|
|
@@ -326,7 +328,7 @@ async function defaultLlmInvoke(prompt, opts = {}) {
|
|
|
326
328
|
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
327
329
|
|
|
328
330
|
if (!opts.endpoint) {
|
|
329
|
-
const resolved = (0,
|
|
331
|
+
const resolved = (0,_llm_validator_providers_js__WEBPACK_IMPORTED_MODULE_2__.resolveProvider)({ role: opts.role || 'hunt' });
|
|
330
332
|
// A REFUSAL (a preset was explicitly configured and declined — e.g. a
|
|
331
333
|
// non-loopback Ollama host, or `local`'s own non-loopback refusal) must
|
|
332
334
|
// propagate here, not silently fall through to the legacy raw-endpoint
|
|
@@ -338,7 +340,7 @@ async function defaultLlmInvoke(prompt, opts = {}) {
|
|
|
338
340
|
if (!resolved.ok && resolved.reason) throw new Error(resolved.reason);
|
|
339
341
|
if (resolved.ok && resolved.config.provider === 'ollama') {
|
|
340
342
|
const oc = resolved.config.ollama;
|
|
341
|
-
const r = await (0,
|
|
343
|
+
const r = await (0,_llm_validator_ollama_provider_js__WEBPACK_IMPORTED_MODULE_3__/* .callOllamaChat */ .L5)({
|
|
342
344
|
host: resolved.config.endpoint,
|
|
343
345
|
model: resolved.config.model,
|
|
344
346
|
messages: [{ role: 'user', content: prompt }],
|
|
@@ -365,12 +367,11 @@ async function defaultLlmInvoke(prompt, opts = {}) {
|
|
|
365
367
|
// Absent, it falls back to the single configured endpoint — so the ordinary
|
|
366
368
|
// single-model path is byte-identical to what it was before consensus existed.
|
|
367
369
|
const endpoint = opts.endpoint || process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
|
|
368
|
-
const res = await
|
|
370
|
+
const res = await (0,_egress_transport_js__WEBPACK_IMPORTED_MODULE_1__/* .modelFetch */ .Ye)(endpoint, {
|
|
369
371
|
method: 'POST',
|
|
370
372
|
headers: { 'content-type': 'application/json' },
|
|
371
373
|
body: JSON.stringify({ prompt }),
|
|
372
|
-
|
|
373
|
-
});
|
|
374
|
+
}, { timeoutMs });
|
|
374
375
|
if (!res.ok) throw new Error(`llm endpoint returned ${res.status}`);
|
|
375
376
|
const body = await res.json();
|
|
376
377
|
return typeof body === 'string' ? body : (body?.text ?? JSON.stringify(body));
|
|
@@ -513,7 +514,7 @@ function resolveLlmInvokeWithDecision(opts = {}) {
|
|
|
513
514
|
// exist, per the block comment above). Checked BEFORE the legacy
|
|
514
515
|
// raw-endpoint fallback below, matching providers.js's own precedence
|
|
515
516
|
// (ollama/local checked before a bare BYO endpoint).
|
|
516
|
-
const resolved = (0,
|
|
517
|
+
const resolved = (0,_llm_validator_providers_js__WEBPACK_IMPORTED_MODULE_2__.resolveProvider)({ role: opts.role || 'hunt' });
|
|
517
518
|
if (!resolved.ok && resolved.reason) {
|
|
518
519
|
// A REFUSAL (non-loopback ollama/local, explicitly configured and
|
|
519
520
|
// declined) is itself a policy decision — same shape as an egress
|
package/dist/5620.index.js
CHANGED
|
@@ -674,9 +674,7 @@ function runOfJob(store, id) {
|
|
|
674
674
|
async function finishRun({ args, root, store, runId, manifest, manifestFile, interrupted }) {
|
|
675
675
|
const { artifacts, problems } = (0,_job_runner_js__WEBPACK_IMPORTED_MODULE_8__/* .loadArtifacts */ .kZ)({ store, runId, root });
|
|
676
676
|
const jobs = store.listJobs(runId);
|
|
677
|
-
const health = (0,_org_health_js__WEBPACK_IMPORTED_MODULE_9__/* .computeOrgHealth */ .WB)({ manifest, jobs });
|
|
678
|
-
for (const p of problems) { const r = health.repositories.find((x) => x.id === p.repoId); if (r) { r.state = 'failed'; r.error = p.problem; } }
|
|
679
|
-
if (problems.length) { health.counts.complete -= problems.filter((p) => health.repositories.find((x) => x.id === p.repoId)).length; health.counts.failed += problems.length; if (health.assurance !== 'red' && manifest.repositories.some((r) => r.required && problems.some((p) => p.repoId === r.id))) health.assurance = 'red'; health.reasons.push(`${problems.length} result(s) could not be verified: ${problems.map((p) => `${p.repoId} (${p.problem})`).join(', ')}`); }
|
|
677
|
+
const health = (0,_org_health_js__WEBPACK_IMPORTED_MODULE_9__/* .applyArtifactProblems */ .Dh)((0,_org_health_js__WEBPACK_IMPORTED_MODULE_9__/* .computeOrgHealth */ .WB)({ manifest, jobs }), manifest, problems);
|
|
680
678
|
|
|
681
679
|
let previous = null;
|
|
682
680
|
const rollupFile = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.statePath)(root, ROLLUP_FILE);
|
|
@@ -1433,7 +1431,7 @@ function blastRadius(graph, repoId) {
|
|
|
1433
1431
|
|
|
1434
1432
|
|
|
1435
1433
|
|
|
1436
|
-
const FLEET_MANIFEST_SCHEMA_KEYS = Object.freeze(['organization', 'repositories', 'workers', 'perRepoTimeout', 'retries', 'resources']);
|
|
1434
|
+
const FLEET_MANIFEST_SCHEMA_KEYS = Object.freeze(['organization', 'repositories', 'workers', 'perRepoTimeout', 'retries', 'resources', 'cacheMaxAge']);
|
|
1437
1435
|
const REPO_KEYS = Object.freeze(['id', 'path', 'required', 'kind', 'internetFacing', 'environment', 'dataClasses', 'trustZone', 'calls', 'produces', 'consumes', 'reads', 'writes', 'providers', 'aliases']);
|
|
1438
1436
|
const RESOURCE_KEYS = Object.freeze(['id', 'kind', 'dataClasses', 'trustZone', 'internetFacing', 'environment', 'aliases']);
|
|
1439
1437
|
const REPO_KINDS = Object.freeze(['service', 'library', 'frontend', 'worker', 'gateway']);
|
|
@@ -1512,12 +1510,15 @@ function validateFleetManifest(doc, { baseDir = '.' } = {}) {
|
|
|
1512
1510
|
if (doc.workers != null && !(Number.isInteger(doc.workers) && doc.workers >= 1 && doc.workers <= 32)) errors.push('workers must be an integer in 1..32');
|
|
1513
1511
|
if (doc.retries != null && !(Number.isInteger(doc.retries) && doc.retries >= 0 && doc.retries <= 5)) errors.push('retries must be an integer in 0..5');
|
|
1514
1512
|
if (doc.perRepoTimeout != null && parseDuration(doc.perRepoTimeout, null) == null) errors.push(`perRepoTimeout is not a valid duration: ${doc.perRepoTimeout}`);
|
|
1513
|
+
if (doc.cacheMaxAge != null && parseDuration(doc.cacheMaxAge, null) == null) errors.push(`cacheMaxAge is not a valid duration: ${doc.cacheMaxAge}`);
|
|
1515
1514
|
if (errors.length) return { ok: false, errors };
|
|
1516
1515
|
return {
|
|
1517
1516
|
ok: true, errors: [],
|
|
1518
1517
|
manifest: {
|
|
1519
1518
|
organization: doc.organization, repositories: repos, resources,
|
|
1520
1519
|
workers: doc.workers ?? 4, retries: doc.retries ?? 1, perRepoTimeoutMs: parseDuration(doc.perRepoTimeout, 20 * 60000),
|
|
1520
|
+
// GA-09: a cached result older than this is stale and is not reused (the feeds it was enriched with have moved on); 7 days by default
|
|
1521
|
+
cacheMaxAgeMs: parseDuration(doc.cacheMaxAge, 7 * 24 * 3600000),
|
|
1521
1522
|
},
|
|
1522
1523
|
};
|
|
1523
1524
|
}
|
|
@@ -1554,7 +1555,7 @@ __webpack_require__.a(__webpack_module__, async (__webpack_handle_async_dependen
|
|
|
1554
1555
|
/* harmony export */ nY: () => (/* binding */ planRun),
|
|
1555
1556
|
/* harmony export */ zD: () => (/* binding */ reclaimDeadWorkers)
|
|
1556
1557
|
/* harmony export */ });
|
|
1557
|
-
/* unused harmony
|
|
1558
|
+
/* unused harmony exports DEFAULTS, DEFAULT_CACHE_MAX_AGE_MS, cacheFreshness */
|
|
1558
1559
|
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(73024);
|
|
1559
1560
|
/* harmony import */ var node_os__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(48161);
|
|
1560
1561
|
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(76760);
|
|
@@ -1637,6 +1638,21 @@ function planRun({ manifest, manifestHash, store, runId = null, config = {}, now
|
|
|
1637
1638
|
* Run (or continue) a planned run until no job is queued or leased by a live worker. Returns when the work is done, the signal
|
|
1638
1639
|
* aborts, or `maxWaitMs` passes with only other workers' leases outstanding.
|
|
1639
1640
|
*/
|
|
1641
|
+
const DEFAULT_CACHE_MAX_AGE_MS = 7 * 24 * 3600000;
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* Is a cache hit fresh enough to reuse (GA-09)? A record with no readable `cachedAt` (written before 0.195.23) is never fresh, and a
|
|
1645
|
+
* record older than `maxAgeMs` (the manifest's `cacheMaxAge`, 7 days by default) is stale. Returns `{ fresh, cacheAgeMs }`.
|
|
1646
|
+
*/
|
|
1647
|
+
function cacheFreshness(hit, maxAgeMs, nowMs) {
|
|
1648
|
+
if (!hit) return { fresh: false, cacheAgeMs: null };
|
|
1649
|
+
const t = typeof hit.cachedAt === 'string' ? Date.parse(hit.cachedAt) : NaN;
|
|
1650
|
+
if (Number.isNaN(t)) return { fresh: false, cacheAgeMs: null };
|
|
1651
|
+
const cacheAgeMs = Math.max(0, nowMs - t);
|
|
1652
|
+
const max = Number.isFinite(maxAgeMs) && maxAgeMs > 0 ? maxAgeMs : DEFAULT_CACHE_MAX_AGE_MS;
|
|
1653
|
+
return { fresh: cacheAgeMs <= max, cacheAgeMs };
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1640
1656
|
async function runJobs({ manifest, store, runId, root, scan = childScan, scanOptions = {}, workers = manifest.workers, leaseMs = DEFAULTS.leaseMs, heartbeatMs = DEFAULTS.heartbeatMs, retryDelayMs = DEFAULTS.retryDelayMs, pollMs = DEFAULTS.pollMs, maxWaitMs = null, signal, now = () => Date.now(), onEvent = () => {}, cacheDir = null, artifactDir = null, useCache = true }) {
|
|
1641
1657
|
const repoById = new Map(manifest.repositories.map((r) => [r.id, r]));
|
|
1642
1658
|
const cacheD = cacheDir ?? (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_6__.statePath)(root, 'fleet', 'cache');
|
|
@@ -1646,7 +1662,11 @@ async function runJobs({ manifest, store, runId, root, scan = childScan, scanOpt
|
|
|
1646
1662
|
const writeArtifact = (job, payload) => {
|
|
1647
1663
|
const text = JSON.stringify({ schema: 'agentic-security/fleet-scan-artifact', version: 1, runId, repoId: job.repoId, epoch: job.epoch, commit: job.commit, ...payload });
|
|
1648
1664
|
const file = node_path__WEBPACK_IMPORTED_MODULE_2__.join(artD, `${job.repoId}.${job.epoch}.json`);
|
|
1649
|
-
|
|
1665
|
+
// written whole or not at all (GA-09): a worker killed mid-write leaves no half artifact for the hash check to find, only a
|
|
1666
|
+
// stray temp file, and the job stays leased until the lease expires and another worker redoes it
|
|
1667
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
1668
|
+
node_fs__WEBPACK_IMPORTED_MODULE_0__.writeFileSync(tmp, text, { mode: 0o600 });
|
|
1669
|
+
node_fs__WEBPACK_IMPORTED_MODULE_0__.renameSync(tmp, file);
|
|
1650
1670
|
return { file, sha256: sha(text) };
|
|
1651
1671
|
};
|
|
1652
1672
|
|
|
@@ -1668,11 +1688,16 @@ async function runJobs({ manifest, store, runId, root, scan = childScan, scanOpt
|
|
|
1668
1688
|
idleSince = null;
|
|
1669
1689
|
const repo = repoById.get(job.repoId);
|
|
1670
1690
|
onEvent({ type: 'start', repo: job.repoId, attempt: job.attempts });
|
|
1671
|
-
const
|
|
1691
|
+
const hit = useCache ? (0,_scan_cache_js__WEBPACK_IMPORTED_MODULE_7__/* .readCache */ .xe)(cacheD, job.cacheKey) : null;
|
|
1692
|
+
// GA-09: a hit is reused only while it is fresh. A result with no recorded time (written before 0.195.23) or older than the
|
|
1693
|
+
// manifest's `cacheMaxAge` is stale: it is scanned again, never folded into a green organization as if it were current.
|
|
1694
|
+
const { fresh, cacheAgeMs } = cacheFreshness(hit, manifest.cacheMaxAgeMs, now());
|
|
1695
|
+
const cached = fresh ? hit : null;
|
|
1696
|
+
if (hit && !cached) onEvent({ type: 'cache-stale', repo: job.repoId, cacheAgeMs });
|
|
1672
1697
|
if (cached) {
|
|
1673
|
-
const a = writeArtifact(job, { entry: cached.entry, findings: cached.findings, findingsTruncated: !!cached.findingsTruncated, lineage: cached.lineage ?? null, cached: true });
|
|
1698
|
+
const a = writeArtifact(job, { entry: cached.entry, findings: cached.findings, findingsTruncated: !!cached.findingsTruncated, lineage: cached.lineage ?? null, cached: true, cachedAt: cached.cachedAt, cacheAgeMs });
|
|
1674
1699
|
const status = cached.entry?.scanHealth && cached.entry.scanHealth.status !== 'complete' ? 'partial' : 'succeeded';
|
|
1675
|
-
if (store.complete(job.jobId, job.epoch, { status, artifactSha256: a.sha256, summary: { total: cached.entry?.total ?? null, cached: true, artifact: node_path__WEBPACK_IMPORTED_MODULE_2__.basename(a.file) }, now: now() })) onEvent({ type: 'done', repo: job.repoId, cached: true, status });
|
|
1700
|
+
if (store.complete(job.jobId, job.epoch, { status, artifactSha256: a.sha256, summary: { total: cached.entry?.total ?? null, cached: true, cacheAgeMs, artifact: node_path__WEBPACK_IMPORTED_MODULE_2__.basename(a.file) }, now: now() })) onEvent({ type: 'done', repo: job.repoId, cached: true, status });
|
|
1676
1701
|
continue;
|
|
1677
1702
|
}
|
|
1678
1703
|
const ac = new AbortController();
|
|
@@ -1946,6 +1971,7 @@ function summarizeLineage(graph) {
|
|
|
1946
1971
|
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
1947
1972
|
|
|
1948
1973
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
1974
|
+
/* harmony export */ Dh: () => (/* binding */ applyArtifactProblems),
|
|
1949
1975
|
/* harmony export */ Vp: () => (/* binding */ renderOrgHealth),
|
|
1950
1976
|
/* harmony export */ WB: () => (/* binding */ computeOrgHealth)
|
|
1951
1977
|
/* harmony export */ });
|
|
@@ -1998,6 +2024,29 @@ function computeOrgHealth({ manifest, jobs }) {
|
|
|
1998
2024
|
return { counts, assurance, reasons, repositories: repos };
|
|
1999
2025
|
}
|
|
2000
2026
|
|
|
2027
|
+
/**
|
|
2028
|
+
* Fold artifact problems (a result whose file is missing or does not match its recorded hash) into the health (GA-09). The repository
|
|
2029
|
+
* becomes `failed`; a required one turns the organization red, an optional one makes it degraded (it used to stay green); the counts
|
|
2030
|
+
* move from whatever state the repository had, not always from `complete`. Returns the same object, changed in place.
|
|
2031
|
+
*/
|
|
2032
|
+
function applyArtifactProblems(health, manifest, problems) {
|
|
2033
|
+
if (!Array.isArray(problems) || !problems.length) return health;
|
|
2034
|
+
const stateKey = { complete: 'complete', partial: 'partial', failed: 'failed', 'timed-out': 'timedOut', cancelled: 'cancelled', 'not-run': 'notRun' };
|
|
2035
|
+
let requiredHit = false; let optionalHit = false;
|
|
2036
|
+
for (const p of problems) {
|
|
2037
|
+
const r = health.repositories.find((x) => x.id === p.repoId);
|
|
2038
|
+
if (!r) continue;
|
|
2039
|
+
if (r.state !== 'failed') { health.counts[stateKey[r.state]]--; health.counts.failed++; }
|
|
2040
|
+
r.state = 'failed'; r.error = p.problem;
|
|
2041
|
+
const required = manifest.repositories.some((m) => m.id === p.repoId && m.required);
|
|
2042
|
+
if (required) requiredHit = true; else optionalHit = true;
|
|
2043
|
+
}
|
|
2044
|
+
if (requiredHit) health.assurance = 'red';
|
|
2045
|
+
else if (optionalHit && health.assurance === 'green') health.assurance = 'degraded';
|
|
2046
|
+
health.reasons.push(`${problems.length} result(s) could not be verified: ${problems.map((p) => `${p.repoId} (${p.problem})`).join(', ')}`);
|
|
2047
|
+
return health;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2001
2050
|
const n = (x) => x.toLocaleString('en-US');
|
|
2002
2051
|
function renderOrgHealth(h) {
|
|
2003
2052
|
const c = h.counts;
|
|
@@ -2131,7 +2180,7 @@ function writeCache(dir, key, payload) {
|
|
|
2131
2180
|
if (!key || !/^[0-9a-f]{64}$/.test(key)) return false;
|
|
2132
2181
|
try {
|
|
2133
2182
|
node_fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
2134
|
-
const body = { schema: 'agentic-security/fleet-scan-cache', version: 1, key, ...payload };
|
|
2183
|
+
const body = { schema: 'agentic-security/fleet-scan-cache', version: 1, key, cachedAt: new Date().toISOString(), ...payload };
|
|
2135
2184
|
const doc = { ...body, digest: sha(JSON.stringify(body)) };
|
|
2136
2185
|
const tmp = node_path__WEBPACK_IMPORTED_MODULE_1__.join(dir, `.${key}.${process.pid}.tmp`);
|
|
2137
2186
|
node_fs__WEBPACK_IMPORTED_MODULE_0__.writeFileSync(tmp, JSON.stringify(doc), { mode: 0o600 });
|
package/dist/5937.index.js
CHANGED
|
@@ -82,10 +82,12 @@ __webpack_require__.d(__webpack_exports__, {
|
|
|
82
82
|
|
|
83
83
|
// EXTERNAL MODULE: external "node:fs"
|
|
84
84
|
var external_node_fs_ = __webpack_require__(73024);
|
|
85
|
-
// EXTERNAL MODULE: ./src/posture/state-dir.js
|
|
86
|
-
var state_dir = __webpack_require__(31174);
|
|
87
85
|
// EXTERNAL MODULE: external "node:path"
|
|
88
86
|
var external_node_path_ = __webpack_require__(76760);
|
|
87
|
+
// EXTERNAL MODULE: external "node:child_process"
|
|
88
|
+
var external_node_child_process_ = __webpack_require__(31421);
|
|
89
|
+
// EXTERNAL MODULE: ./src/posture/state-dir.js
|
|
90
|
+
var state_dir = __webpack_require__(31174);
|
|
89
91
|
// EXTERNAL MODULE: external "node:crypto"
|
|
90
92
|
var external_node_crypto_ = __webpack_require__(77598);
|
|
91
93
|
// EXTERNAL MODULE: ./src/posture/rule-compiler/ir.js
|
|
@@ -149,6 +151,8 @@ function verifyLedger(ledger) {
|
|
|
149
151
|
function historyFor(ledger, ruleId) { return ledger.entries.filter((e) => e.ruleId === ruleId); }
|
|
150
152
|
function stateOf(ledger, ruleId) { return currentState(ledger, ruleId); }
|
|
151
153
|
|
|
154
|
+
// EXTERNAL MODULE: ./src/posture/integrity.js
|
|
155
|
+
var integrity = __webpack_require__(71130);
|
|
152
156
|
// EXTERNAL MODULE: ./src/posture/rule-pack-signing.js
|
|
153
157
|
var rule_pack_signing = __webpack_require__(99527);
|
|
154
158
|
;// CONCATENATED MODULE: ./src/posture/rule-compiler/store.js
|
|
@@ -164,25 +168,45 @@ var rule_pack_signing = __webpack_require__(99527);
|
|
|
164
168
|
|
|
165
169
|
|
|
166
170
|
|
|
171
|
+
|
|
167
172
|
const DIR = 'rule-compiler';
|
|
168
173
|
const EXT = { javascript: 'js', typescript: 'ts', python: 'py', go: 'go', ruby: 'rb', java: 'java', rust: 'rs', csharp: 'cs', php: 'php', any: 'txt' };
|
|
169
174
|
|
|
170
175
|
function ruleDir(scanRoot, id) { return (0,state_dir.statePath)(scanRoot, DIR, id); }
|
|
171
176
|
|
|
172
|
-
|
|
177
|
+
/**
|
|
178
|
+
* The lifecycle ledger is hash-chained (lifecycle.js) AND signed with the per-install HMAC key (the same key as last-scan.json),
|
|
179
|
+
* since 0.195.23 (GA-07): the chain alone could be recomputed by anyone who could edit the file. A ledger that fails either check,
|
|
180
|
+
* or that cannot be parsed, is returned WITH `tampered: true` and its reason, and `saveLedger` refuses to write it, so a bad ledger
|
|
181
|
+
* is never quietly replaced by a fresh one and its history lost. An unsigned ledger with entries (written before 0.195.23) is
|
|
182
|
+
* accepted only with AGENTIC_SECURITY_RULES_UNSIGNED=1, the same switch that accepts an unsigned rules override, and is signed on
|
|
183
|
+
* its next save.
|
|
184
|
+
*/
|
|
185
|
+
function loadLedger(scanRoot, env = process.env) {
|
|
173
186
|
const f = (0,state_dir.statePath)(scanRoot, DIR, 'ledger.json');
|
|
174
187
|
if (!external_node_fs_.existsSync(f)) return newLedger();
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
188
|
+
let text;
|
|
189
|
+
try { text = external_node_fs_.readFileSync(f, 'utf8'); } catch (e) { return { ...newLedger(), tampered: true, tamperReason: `cannot read the ledger: ${e.message}` }; }
|
|
190
|
+
let ledger;
|
|
191
|
+
try { ledger = JSON.parse(text); } catch (e) { return { ...newLedger(), tampered: true, tamperReason: `the ledger is not valid JSON: ${e.message}` }; }
|
|
192
|
+
if (!ledger || !Array.isArray(ledger.entries)) return { ...newLedger(), tampered: true, tamperReason: 'the ledger has no entries array' };
|
|
193
|
+
const v = verifyLedger(ledger);
|
|
194
|
+
if (!v.ok) return { ...ledger, tampered: true, tamperReason: v.reason };
|
|
195
|
+
const sig = (0,integrity/* verifyLastScan */.Ef)(text, `${f}.sig`);
|
|
196
|
+
if (sig === false) return { ...ledger, tampered: true, tamperReason: 'the ledger signature does not verify under this install key' };
|
|
197
|
+
if (sig === null && ledger.entries.length && env.AGENTIC_SECURITY_RULES_UNSIGNED !== '1') {
|
|
198
|
+
return { ...ledger, tampered: true, tamperReason: 'the ledger is unsigned (written before 0.195.23 or by another tool); set AGENTIC_SECURITY_RULES_UNSIGNED=1 once to accept and re-sign it' };
|
|
199
|
+
}
|
|
200
|
+
return ledger;
|
|
180
201
|
}
|
|
181
202
|
|
|
182
203
|
function saveLedger(scanRoot, ledger) {
|
|
204
|
+
if (ledger?.tampered) return { ok: false, reason: `refusing to overwrite a ledger that failed its integrity check (${ledger.tamperReason})` };
|
|
183
205
|
if (!(0,state_dir.ensureStateDir)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
|
|
184
206
|
const f = (0,state_dir.statePath)(scanRoot, DIR, 'ledger.json');
|
|
185
|
-
|
|
207
|
+
const text = JSON.stringify(ledger, null, 2);
|
|
208
|
+
const ok = (0,state_dir.safeWriteState)(f, text) && (0,state_dir.safeWriteState)(`${f}.sig`, (0,integrity/* signLastScan */.lU)(text));
|
|
209
|
+
return { ok };
|
|
186
210
|
}
|
|
187
211
|
|
|
188
212
|
function saveIR(scanRoot, ir) {
|
|
@@ -455,6 +479,8 @@ var reproducer = __webpack_require__(37055);
|
|
|
455
479
|
|
|
456
480
|
|
|
457
481
|
|
|
482
|
+
|
|
483
|
+
|
|
458
484
|
function loadFinding(root, id) {
|
|
459
485
|
const p = (0,state_dir.statePath)(root, 'last-scan.json');
|
|
460
486
|
if (!external_node_fs_.existsSync(p)) return null;
|
|
@@ -469,6 +495,12 @@ function readJsonArg(pathOrInline) {
|
|
|
469
495
|
|
|
470
496
|
async function cmdRules(args, root) {
|
|
471
497
|
const sub = args._[1];
|
|
498
|
+
// GA-07: a ledger that failed its integrity check (broken chain, bad or missing signature) refuses EVERY command, not only the
|
|
499
|
+
// ones that change state; nothing below can read a tampered history as fact or write over it.
|
|
500
|
+
if (['synthesize', 'evaluate', 'promote', 'status', 'rollback', 'cost', 'regress'].includes(sub)) {
|
|
501
|
+
const guard = loadLedger(root);
|
|
502
|
+
if (guard.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${guard.tamperReason}).`); return 4; }
|
|
503
|
+
}
|
|
472
504
|
if (sub === 'synthesize') return cmdSynthesize(args, root);
|
|
473
505
|
if (sub === 'evaluate') return cmdEvaluate(args, root);
|
|
474
506
|
if (sub === 'promote') return cmdPromote(args, root);
|
|
@@ -476,7 +508,7 @@ async function cmdRules(args, root) {
|
|
|
476
508
|
if (sub === 'rollback') return cmdRollback(args, root);
|
|
477
509
|
if (sub === 'cost') return cmdCost(args, root);
|
|
478
510
|
if (sub === 'regress') return cmdRegress(args, root);
|
|
479
|
-
console.error('usage: rules synthesize <finding-id> --spec <file.json> [--auto] [--max-attempts N] | evaluate <rule-id> | promote <rule-id> | status [rule-id] | rollback <rule-id> | cost <rule-id>');
|
|
511
|
+
console.error('usage: rules synthesize <finding-id> --spec <file.json> [--auto] [--max-attempts N] | evaluate <rule-id> | promote <rule-id> --reviewed-by <person> [--scope <text>] | status [rule-id] | rollback <rule-id> | cost <rule-id> | regress [rule-id]');
|
|
480
512
|
return 4;
|
|
481
513
|
}
|
|
482
514
|
|
|
@@ -495,7 +527,7 @@ async function cmdSynthesize(args, root) {
|
|
|
495
527
|
// The candidate pattern is the model's or the person's, when they gave one. Without one, or with --auto, the synthesis loop proposes
|
|
496
528
|
// from the vulnerable and patched code, scores each attempt on the real corpus and its mutations, and revises (see proposal.js).
|
|
497
529
|
let finalSpec = spec; let synthesis = null;
|
|
498
|
-
const corpusSpec = { originalCode: spec.originalCode, patchedCode: spec.patchedCode ?? null, nearbyCleanCode: spec.nearbyCleanCode ?? [], protectedVariantsCode: spec.protectedVariantsCode ?? [] };
|
|
530
|
+
const corpusSpec = { originalCode: spec.originalCode, patchedCode: spec.patchedCode ?? null, nearbyCleanCode: spec.nearbyCleanCode ?? [], protectedVariantsCode: spec.protectedVariantsCode ?? [], independentVulnerableCode: spec.independentVulnerableCode ?? [], independentCleanCode: spec.independentCleanCode ?? [] };
|
|
499
531
|
const leak = (0,promotion/* checkLeakage */.r_)(JSON.stringify(corpusSpec), { benchmarkNames: args.flags['benchmark-names'] ? String(args.flags['benchmark-names']).split(',') : [], fileNames: spec.fileNames ?? [] });
|
|
500
532
|
if (!leak.ok) { console.error(`Refusing: the material for this rule shows benchmark or answer-key signals (${leak.offendingTerms.join('; ')}). Nothing was written.`); return 4; }
|
|
501
533
|
if (args.flags.auto || (!spec.match && !spec.sink)) {
|
|
@@ -554,7 +586,7 @@ async function cmdEvaluate(args, root) {
|
|
|
554
586
|
originatingFinding: ir.provenance.originatingFindingId, originatingCommit: ir.provenance.originatingCommit, evidenceState: ir.provenance.evidenceState,
|
|
555
587
|
generatingModel: ir.provenance.model, promptConfigVersion: ir.provenance.promptConfigVersion,
|
|
556
588
|
positiveCorpusHash: corpusHash(corpus.positives), negativeCorpusHash: corpusHash(corpus.negatives),
|
|
557
|
-
benchmarkResults: { precision: gate.evaluation.score.precision, recall: gate.evaluation.score.recall, tp: gate.evaluation.score.tp, fp: gate.evaluation.score.fp, fn: gate.evaluation.score.fn, tn: gate.evaluation.score.tn, protectedFailures: gate.evaluation.score.protectedFailures, thresholds: gate.evaluation.thresholds, gate: gate.pass ? 'passed' : 'failed', reasons: gate.reasons },
|
|
589
|
+
benchmarkResults: { precision: gate.evaluation.score.precision, recall: gate.evaluation.score.recall, tp: gate.evaluation.score.tp, fp: gate.evaluation.score.fp, fn: gate.evaluation.score.fn, tn: gate.evaluation.score.tn, protectedFailures: gate.evaluation.score.protectedFailures, independent: gate.independent, thresholds: gate.evaluation.thresholds, gate: gate.pass ? 'passed' : 'failed', reasons: gate.reasons },
|
|
558
590
|
leakageCheck: leakage, mutatorCoverage: (0,mutation/* mutatorCoverage */.xv)(corpusSpec.originalCode), discoveryCost: corpusSpec.discoveryCost ?? prev?.discoveryCost ?? null,
|
|
559
591
|
promotionEvent: prev?.promotionEvent ?? null, evaluatedAt: new Date().toISOString(),
|
|
560
592
|
});
|
|
@@ -570,23 +602,63 @@ async function cmdEvaluate(args, root) {
|
|
|
570
602
|
function isStr(v) { return typeof v === 'string' && v.length > 0; }
|
|
571
603
|
const fmtPct = (n) => (n == null ? 'unmeasured' : `${(n * 100).toFixed(1)}%`);
|
|
572
604
|
|
|
605
|
+
/** Does this commit exist in the project's history? `true`, `false`, or `null` when the project is not a git repository. */
|
|
606
|
+
function commitExists(root, sha) {
|
|
607
|
+
if (!external_node_fs_.existsSync(external_node_path_.join(root, '.git'))) return null;
|
|
608
|
+
try { (0,external_node_child_process_.execFileSync)('git', ['cat-file', '-e', `${sha}^{commit}`], { cwd: root, stdio: 'ignore' }); return true; } catch { return false; }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const NOT_A_REVIEWER = /^(?:agent|model|auto|bot|llm|ai)$/i;
|
|
612
|
+
|
|
613
|
+
// Promotion is a reviewed, human decision (GA-07). It needs the reviewer's name, a rule that is exactly the one that was evaluated, a
|
|
614
|
+
// provenance that checks out, and no conflict with a rule that already exists; a model's recommendation on its own promotes nothing.
|
|
573
615
|
async function cmdPromote(args, root) {
|
|
574
616
|
const id = args._[2];
|
|
575
|
-
if (!id) { console.error('usage: rules promote <rule-id>'); return 4; }
|
|
617
|
+
if (!id) { console.error('usage: rules promote <rule-id> --reviewed-by <person> [--scope <text>] [--reason <text>]'); return 4; }
|
|
618
|
+
const reviewer = args.flags['reviewed-by'];
|
|
619
|
+
if (!isStr(reviewer) || NOT_A_REVIEWER.test(reviewer.trim())) {
|
|
620
|
+
console.error('usage: rules promote <rule-id> --reviewed-by <person>. A promotion records the person who reviewed the rule and its scope; a model or agent recommendation is not a review, so nothing is promoted without a named reviewer.');
|
|
621
|
+
return 4;
|
|
622
|
+
}
|
|
576
623
|
const ledger = loadLedger(root);
|
|
577
624
|
if (ledger.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${ledger.tamperReason}).`); return 4; }
|
|
578
625
|
if (stateOf(ledger, id) !== 'VALIDATED') { console.error(`Rule ${id} is not VALIDATED (run rules evaluate first); refusing to promote.`); return 4; }
|
|
579
626
|
const ir = loadIR(root, id);
|
|
580
627
|
if (!ir) { console.error(`No rule IR found for ${id}.`); return 4; }
|
|
628
|
+
// the rule promoted must be the rule evaluated: an IR edited after `rules evaluate` has no evidence
|
|
629
|
+
const prov = loadProvenance(root, id);
|
|
630
|
+
if (!prov) { console.error(`Promotion refused: no evaluation record for ${id}; run rules evaluate first.`); return 4; }
|
|
631
|
+
if (prov.ruleHash !== (0,rule_compiler_ir/* ruleIRDigest */.GE)(ir)) { console.error(`Promotion refused: the rule ${id} changed after it was evaluated (evaluated ${String(prov.ruleHash).slice(0, 12)}, now ${(0,rule_compiler_ir/* ruleIRDigest */.GE)(ir).slice(0, 12)}); run rules evaluate again.`); return 4; }
|
|
632
|
+
// a named originating commit must be real history, not a typed-in string
|
|
633
|
+
const commit = ir.provenance?.originatingCommit;
|
|
634
|
+
if (commit) {
|
|
635
|
+
const exists = commitExists(root, commit);
|
|
636
|
+
if (exists === false) { console.error(`Promotion refused: originating commit ${commit} is not in this repository's history (invalid provenance).`); return 4; }
|
|
637
|
+
if (exists === null) { console.error(`Promotion refused: originating commit ${commit} cannot be verified because this project is not a git repository.`); return 4; }
|
|
638
|
+
}
|
|
639
|
+
// conflicts: a hand-written rule file with the same id, or a promoted rule with the identical match
|
|
640
|
+
const live = (0,state_dir.statePath)(root, 'rules', `${id}.yml`);
|
|
641
|
+
let firstLine = null; // null: no file there (or unreadable, which is treated as absent below only when the read fails with ENOENT)
|
|
642
|
+
try { firstLine = external_node_fs_.readFileSync(live, 'utf8').split('\n')[0]; } catch (e) { if (e.code !== 'ENOENT') firstLine = ''; }
|
|
643
|
+
if (firstLine !== null && !firstLine.startsWith('# Compiled by the rule compiler')) { console.error(`Promotion refused: a rule file that the compiler did not write already exists at ${live}; choose another rule id or remove that file deliberately.`); return 4; }
|
|
644
|
+
for (const other of promotedRuleIds(ledger)) {
|
|
645
|
+
if (other === id) continue;
|
|
646
|
+
const oir = loadIR(root, other);
|
|
647
|
+
if (oir && JSON.stringify(oir.match) === JSON.stringify(ir.match) && JSON.stringify(oir.languages) === JSON.stringify(ir.languages)) {
|
|
648
|
+
console.error(`Promotion refused: ${id} duplicates the promoted rule ${other} (identical match on the same languages); roll one back or narrow this one.`);
|
|
649
|
+
return 4;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
581
652
|
// other rules may have been promoted since this one was evaluated: hold it to the false-positive corpus as it is NOW
|
|
582
653
|
const fpNow = falsePositiveHits(ir, sharedFalsePositiveCorpus(root, ledger, id));
|
|
583
654
|
if (fpNow.length) { console.error(`Promotion refused: ${id} fires on ${fpNow.length} item(s) of the shared false-positive corpus (${fpNow.slice(0, 3).map((h) => h.label).join(', ')}). Re-evaluate or narrow the rule.`); return 1; }
|
|
584
655
|
const pub = publishCustomRule(root, ir);
|
|
585
656
|
if (!pub.ok) { console.error(`Promotion refused: ${pub.reason}`); return 4; }
|
|
586
|
-
const
|
|
657
|
+
const scope = isStr(args.flags.scope) ? args.flags.scope : null;
|
|
658
|
+
const review = { reviewer: reviewer.trim(), scope, independent: prov.benchmarkResults?.independent ?? null };
|
|
659
|
+
const t = recordTransition(ledger, { ruleId: id, from: 'VALIDATED', to: 'PROMOTED', reason: args.flags.reason || 'promoted', evidence: review, actor: args.flags.actor || reviewer.trim() });
|
|
587
660
|
saveLedger(root, ledger);
|
|
588
|
-
|
|
589
|
-
if (prov) saveProvenance(root, id, { ...prov, promotionEvent: { at: new Date().toISOString(), actor: args.flags.actor || 'operator', reason: args.flags.reason || 'promoted', ruleHash: (0,rule_compiler_ir/* ruleIRDigest */.GE)(ir) } });
|
|
661
|
+
saveProvenance(root, id, { ...prov, promotionEvent: { at: new Date().toISOString(), actor: args.flags.actor || reviewer.trim(), reviewer: reviewer.trim(), scope, reason: args.flags.reason || 'promoted', ruleHash: (0,rule_compiler_ir/* ruleIRDigest */.GE)(ir) } });
|
|
590
662
|
console.log(`Rule ${id}: VALIDATED -> PROMOTED. Published to ${pub.file}.`);
|
|
591
663
|
// Publishing is not trusting. Rule packs are signature-checked so a rule dropped into a repository cannot run in someone's CI; sign this
|
|
592
664
|
// one with the operator's key when there is one, and say plainly whether the scanner will load it.
|
package/dist/7838.index.js
CHANGED
|
@@ -34,11 +34,15 @@ function toRuntimeRule(ir) {
|
|
|
34
34
|
|
|
35
35
|
const EXT = { javascript: 'x.js', typescript: 'x.ts', python: 'x.py', go: 'x.go', ruby: 'x.rb', java: 'x.java', rust: 'x.rs', csharp: 'x.cs', php: 'x.php', any: 'x.txt' };
|
|
36
36
|
|
|
37
|
-
function buildCorpus(ir, { originalCode, patchedCode = null, nearbyCleanCode = [], protectedVariantsCode = [] } = {}) {
|
|
37
|
+
function buildCorpus(ir, { originalCode, patchedCode = null, nearbyCleanCode = [], protectedVariantsCode = [], independentVulnerableCode = [], independentCleanCode = [] } = {}) {
|
|
38
38
|
const positives = [{ label: 'original', code: originalCode }];
|
|
39
39
|
for (const v of (0,_mutation_js__WEBPACK_IMPORTED_MODULE_0__/* .generateVariants */ .G4)(originalCode)) positives.push({ label: `sibling:${v.mutator}`, code: v.code });
|
|
40
|
+
// Independent examples (GA-07): vulnerable and safe code that is NOT the source finding and NOT a mutation of it, supplied by the
|
|
41
|
+
// reviewer from other places. They are never mutated, so they score as what they are: evidence from outside the rule's own material.
|
|
42
|
+
independentVulnerableCode.forEach((code, i) => positives.push({ label: `independent-vulnerable-${i}`, code }));
|
|
40
43
|
|
|
41
44
|
const negatives = [];
|
|
45
|
+
independentCleanCode.forEach((code, i) => negatives.push({ label: `independent-clean-${i}`, code }));
|
|
42
46
|
if (patchedCode) {
|
|
43
47
|
negatives.push({ label: 'patched', code: patchedCode });
|
|
44
48
|
for (const v of (0,_mutation_js__WEBPACK_IMPORTED_MODULE_0__/* .generateVariants */ .G4)(patchedCode)) negatives.push({ label: `patched-sibling:${v.mutator}`, code: v.code });
|
|
@@ -74,8 +78,12 @@ function scoreRule(ir, corpus) {
|
|
|
74
78
|
const recall = corpus.positives.length === 0 ? null : tp / corpus.positives.length;
|
|
75
79
|
// "Protected negative failures" per PRD 3: a rule firing on a sanitizer/guard-carrying variant specifically.
|
|
76
80
|
const protectedFailures = negResults.filter((n) => n.label.startsWith('protected-') && n.fired).length;
|
|
81
|
+
// Independent evidence, counted apart: how many examples from outside the finding's own material there were, and how they scored.
|
|
82
|
+
const indPos = posResults.filter((p) => p.label.startsWith('independent-vulnerable-'));
|
|
83
|
+
const indNeg = negResults.filter((n) => n.label.startsWith('independent-clean-'));
|
|
84
|
+
const independent = { positives: indPos.length, negatives: indNeg.length, detected: indPos.filter((p) => p.fired).length, falseAlarms: indNeg.filter((n) => n.fired).length };
|
|
77
85
|
|
|
78
|
-
return { precision, recall, tp, fp, tn, fn, protectedFailures, positives: posResults, negatives: negResults };
|
|
86
|
+
return { precision, recall, tp, fp, tn, fn, protectedFailures, independent, positives: posResults, negatives: negResults };
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
|
|
@@ -169,9 +177,18 @@ function validateRuleIR(ir) {
|
|
|
169
177
|
return errs;
|
|
170
178
|
}
|
|
171
179
|
|
|
180
|
+
/** JSON with every object's keys sorted at every depth, so the digest does not depend on insertion order. */
|
|
181
|
+
function stableStringify(v) {
|
|
182
|
+
if (Array.isArray(v)) return `[${v.map(stableStringify).join(',')}]`;
|
|
183
|
+
if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(v[k])}`).join(',')}}`;
|
|
184
|
+
return JSON.stringify(v);
|
|
185
|
+
}
|
|
186
|
+
|
|
172
187
|
function ruleIRDigest(ir) {
|
|
173
188
|
const { state: _s, ...rest } = ir;
|
|
174
|
-
|
|
189
|
+
// Until 0.195.23 this passed the TOP-LEVEL key list as the JSON replacer, which also filters nested objects: `match.pattern` and
|
|
190
|
+
// every structural regex were serialized as `{}`, so the digest never covered the one thing a rule is. Sorted at every depth now.
|
|
191
|
+
return (0,_proof_fabric_reproducer_js__WEBPACK_IMPORTED_MODULE_0__/* .sha256 */ .sc)(stableStringify(rest));
|
|
175
192
|
}
|
|
176
193
|
|
|
177
194
|
/** Build a DRAFT rule IR. Caller supplies the candidate pattern — this module never invents one. */
|
|
@@ -386,7 +403,11 @@ function mutatorCoverage(code) {
|
|
|
386
403
|
// actually recorded, and nothing in this module writes to it.
|
|
387
404
|
|
|
388
405
|
|
|
389
|
-
|
|
406
|
+
// Since 0.195.23 (GA-07) promotion also needs independent evidence: at least one vulnerable and one safe example that are not the
|
|
407
|
+
// source finding or a mutation of it, and the rule must detect every independent vulnerable example and stay quiet on every
|
|
408
|
+
// independent safe one. The synthesis loop still scores attempts without them (`evaluateRule`); only the promotion decision
|
|
409
|
+
// (`gatePromotion`) requires them, so a candidate can be proposed before a reviewer has gathered the outside examples.
|
|
410
|
+
const PROMOTION_DEFAULTS = Object.freeze({ minPrecision: 0.95, minRecall: 0.80, maxProtectedFailures: 0, minIndependentVulnerable: 1, minIndependentClean: 1 });
|
|
390
411
|
|
|
391
412
|
// Deliberately independent of posture/evaluation/custody.js: that module's term list and guard are reserved for
|
|
392
413
|
// the evaluation custodian's sealed-label boundary (QA-002.AC03), and a test enforces that nothing outside
|
|
@@ -441,7 +462,13 @@ function gatePromotion({ ir, corpus, leakageCheck, thresholds } = {}) {
|
|
|
441
462
|
const evalResult = evaluateRule(ir, corpus, thresholds);
|
|
442
463
|
const reasons = [...evalResult.reasons];
|
|
443
464
|
if (!leakageCheck || leakageCheck.ok !== true) reasons.push(leakageCheck ? `leakage check failed: ${leakageCheck.offendingTerms.join(', ')}` : 'leakage check was not run');
|
|
444
|
-
|
|
465
|
+
const t = evalResult.thresholds;
|
|
466
|
+
const ind = evalResult.score.independent ?? { positives: 0, negatives: 0, detected: 0, falseAlarms: 0 };
|
|
467
|
+
if (ind.positives < t.minIndependentVulnerable) reasons.push(`${ind.positives} independent vulnerable example(s) beyond the source finding and its mutations (min ${t.minIndependentVulnerable}): supply independentVulnerableCode`);
|
|
468
|
+
else if (ind.detected < ind.positives) reasons.push(`misses ${ind.positives - ind.detected} of ${ind.positives} independent vulnerable example(s)`);
|
|
469
|
+
if (ind.negatives < t.minIndependentClean) reasons.push(`${ind.negatives} independent safe example(s) (min ${t.minIndependentClean}): supply independentCleanCode`);
|
|
470
|
+
else if (ind.falseAlarms > 0) reasons.push(`fires on ${ind.falseAlarms} of ${ind.negatives} independent safe example(s)`);
|
|
471
|
+
return { pass: reasons.length === 0, reasons, evaluation: evalResult, independent: ind };
|
|
445
472
|
}
|
|
446
473
|
|
|
447
474
|
|