@clear-capabilities/agentic-security-scanner 0.139.1 → 0.140.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.
@@ -1 +1 @@
1
- 6644269053578a17693b6dbb13795c1195ddf4faf6816063d8fb5e33c78bdbae agentic-security.mjs
1
+ 800f9251808cc9e453cd1604b2375ad445aa4a00382ebe049294ac758622d258 agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.139.1",
3
+ "version": "0.140.0",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin — SAST, SCA (function-level reachability + CISA KEV), secrets, IaC, prompt-injection, MCP/agent-tool audit, auth/authZ deep analysis, attack chains, PoC generation, business logic, toxic-combinations scoring, SBOM, pipeline integrity, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -64,7 +64,7 @@
64
64
  "test:smoke": "node --test test/smoke.test.js test/demo-app.test.js",
65
65
  "test:glob": "node --test test/glob-compat.test.js",
66
66
  "test:sast": "node --test test/sibling-guard.test.js test/comment-blindness.test.js test/crypto-specialist.test.js test/llm.test.js test/llm-cost-advisor.test.js test/llm-owasp.test.js test/logic.test.js test/authz.test.js test/model-load.test.js test/prompt-template.test.js test/business-logic.test.js test/python-sinks.test.js test/phase1-detectors.test.js test/phase2-detectors.test.js test/phase3-v3.test.js test/phase7-extensions.test.js test/phase8-extensions.test.js test/new-cwe-detectors.test.js test/file-upload.test.js test/llmsecops-detectors.test.js test/db-taint.test.js test/dart-swift.test.js test/zip-slip-containment-guard.test.js test/redos-anchoring.test.js test/path-guard-validator-call.test.js test/redos-nfa.test.js test/weak-randomness.test.js test/csharp-pipeline.test.js test/post-quantum-crypto.test.js test/web3-advanced.test.js test/cloud-iam-k8s.test.js test/crypto-protocol.test.js test/ml-supply-chain.test.js test/mcp-audit-scan.test.js test/wrong-context-sanitizer.test.js test/sanitizer-context.test.js test/frontend-hygiene.test.js test/csv-injection.test.js test/stored-taint.test.js test/tree-sitter-sinks.test.js test/kotlin-structural.test.js test/ruby-php-structural.test.js test/java-csharp-structural.test.js test/guard-recognition.test.js test/guard-window-identifier-correlation.test.js test/js-python-framework-structural.test.js test/go-structural.test.js test/secret-concat.test.js test/secret-redaction.test.js test/xss-reflected-multilang.test.js test/code-injection-multilang.test.js test/xxe-multilang.test.js test/xpath-injection-multilang.test.js test/gapfill-batch13.test.js test/agent-untrusted-flow.test.js test/api-authz.test.js test/event-entrypoint.test.js test/iac-terraform.test.js test/cross-service.test.js test/rbac-consistency.test.js test/adversarial-robustness.test.js test/secret-entropy.test.js test/comment-strip.test.js test/juliet-path-filter-gate.test.js test/java-bench-shape-opt-in.test.js test/reachability-missing-callgraph-data.test.js test/rate-limit.test.js test/auth-signals.test.js test/convention-deviation.test.js test/resource-exhaustion.test.js test/resource-exhaustion-realworld.test.js test/redirect-toctou.test.js test/redirect-toctou-realworld.test.js test/codegen-sink.test.js test/ownership-authz.test.js test/ownership-authz-realworld.test.js test/t21-precision-audit.test.js",
67
- "test:posture": "node --test test/privacy-framework.test.js test/material-change.test.js test/drift.test.js test/scorecard.test.js test/accuracy-scorecard.test.js test/scorecard-gate.test.js test/predeploy-gate.test.js test/release-check.test.js test/pre-push-gate.test.js test/dependency-currency.test.js test/package-contents-check.test.js test/gate-verdict-cache.test.js test/independent-population.test.js test/why-missed.test.js test/independent-scoring.test.js test/evidence-bundle.test.js test/discovery-memory.test.js test/mttr.test.js test/license-policy.test.js test/aibom.test.js test/sbom.test.js test/api-inventory.test.js test/iam-policy.test.js test/container.test.js test/container-runtime.test.js test/image-packages.test.js test/kev.test.js test/dep-confusion.test.js test/sca-deprecated.test.js test/sca-batch.test.js test/composite-risk.test.js test/epss-enrich.test.js test/sca-coverage.test.js test/gradle-deps.test.js test/sca-route-reachable.test.js test/sca-policy.test.js test/sca-verdict.test.js test/install-script.test.js test/sca-linked-findings.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.test.js test/deterministic-fix.test.js test/falsification.test.js test/verification-separation.test.js test/logic-claims.test.js test/attestation.test.js test/verify-attestation-cli.test.js test/comparison.test.js test/determinism-cross-machine.test.js test/fix-honesty-gate.test.js test/model-routing.test.js test/root-cause-sweep.test.js test/entrypoint-inventory.test.js test/relevance.test.js test/untrusted.test.js test/agent-hardening.test.js test/rule-synthesis.test.js test/policy-gate.test.js test/agents-memory.test.js test/cve-lookup.test.js test/cve-alert-daemon.test.js test/fix-verify-loop.test.js test/fix-verify-tests.test.js test/fix-acceptance.test.js test/exploitability-probability.test.js test/history-scan.test.js test/viral-features.test.js test/viral-v074.test.js test/state-dir.test.js test/ignore-pragma.test.js test/license-graph.test.js test/secret-live-check.test.js test/attack-taxonomy.test.js test/triage-memory.test.js test/pr-augment.test.js test/chat-batch2.test.js test/chat-batch3.test.js test/chat-batch4.test.js test/chat-batch5.test.js test/chat-batch6.test.js test/router.test.js test/legacy-alias-redirect.test.js test/cache-economics.test.js test/coverage-report.test.js test/corpus-status.test.js test/provenance.test.js test/secret-history.test.js test/execution-proof.test.js test/fix-metrics.test.js test/corpus-enroll.test.js test/prove-findings.test.js test/corpus-match.test.js test/corpus-provenance.test.js test/learning-quorum.test.js test/llm-cache-integrity.test.js test/proof-artifact.test.js test/providers.test.js test/fleet.test.js test/autopilot.test.js test/autopilot-cli.test.js test/verifier-independence.test.js test/integrity-legacy-key.test.js test/suppression-visibility.test.js test/cost-ceiling.test.js test/local-endpoint.test.js test/model-trust.test.js test/vuln-archaeology.test.js test/scan-checkpoint.test.js test/llm-redact.test.js test/world-class-batch2.test.js test/world-class-modules.test.js test/integration-end-to-end.test.js test/streak-cli-wiring.test.js test/stack-playbook.test.js test/machine-output-flag.test.js test/only-flag-channels.test.js test/confidence.test.js test/security-trend.test.js test/calibration-drift.test.js test/validator-metrics.test.js test/clustering-ir-taint-line.test.js",
67
+ "test:posture": "node --test test/privacy-framework.test.js test/material-change.test.js test/drift.test.js test/scorecard.test.js test/accuracy-scorecard.test.js test/scorecard-gate.test.js test/predeploy-gate.test.js test/release-check.test.js test/pre-push-gate.test.js test/dependency-currency.test.js test/package-contents-check.test.js test/gate-verdict-cache.test.js test/independent-population.test.js test/why-missed.test.js test/independent-scoring.test.js test/evidence-bundle.test.js test/discovery-memory.test.js test/mttr.test.js test/license-policy.test.js test/aibom.test.js test/sbom.test.js test/api-inventory.test.js test/iam-policy.test.js test/container.test.js test/container-runtime.test.js test/image-packages.test.js test/kev.test.js test/dep-confusion.test.js test/sca-deprecated.test.js test/sca-batch.test.js test/composite-risk.test.js test/epss-enrich.test.js test/sca-coverage.test.js test/gradle-deps.test.js test/sca-route-reachable.test.js test/sca-policy.test.js test/sca-verdict.test.js test/install-script.test.js test/sca-linked-findings.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.test.js test/deterministic-fix.test.js test/falsification.test.js test/verification-separation.test.js test/logic-claims.test.js test/attestation.test.js test/verify-attestation-cli.test.js test/comparison.test.js test/determinism-cross-machine.test.js test/fix-honesty-gate.test.js test/model-routing.test.js test/root-cause-sweep.test.js test/entrypoint-inventory.test.js test/relevance.test.js test/untrusted.test.js test/agent-hardening.test.js test/rule-synthesis.test.js test/policy-gate.test.js test/agents-memory.test.js test/cve-lookup.test.js test/cve-alert-daemon.test.js test/fix-verify-loop.test.js test/fix-verify-tests.test.js test/fix-acceptance.test.js test/exploitability-probability.test.js test/history-scan.test.js test/viral-features.test.js test/viral-v074.test.js test/state-dir.test.js test/ignore-pragma.test.js test/license-graph.test.js test/secret-live-check.test.js test/attack-taxonomy.test.js test/triage-memory.test.js test/pr-augment.test.js test/chat-batch2.test.js test/chat-batch3.test.js test/chat-batch4.test.js test/chat-batch5.test.js test/chat-batch6.test.js test/router.test.js test/legacy-alias-redirect.test.js test/cache-economics.test.js test/coverage-report.test.js test/corpus-status.test.js test/provenance.test.js test/secret-history.test.js test/execution-proof.test.js test/fix-metrics.test.js test/corpus-enroll.test.js test/prove-findings.test.js test/corpus-match.test.js test/corpus-provenance.test.js test/learning-quorum.test.js test/llm-cache-integrity.test.js test/proof-artifact.test.js test/providers.test.js test/fleet.test.js test/autopilot.test.js test/autopilot-cli.test.js test/verifier-independence.test.js test/integrity-legacy-key.test.js test/suppression-visibility.test.js test/cost-ceiling.test.js test/local-endpoint.test.js test/model-trust.test.js test/vuln-archaeology.test.js test/scan-checkpoint.test.js test/llm-redact.test.js test/world-class-batch2.test.js test/world-class-modules.test.js test/integration-end-to-end.test.js test/streak-cli-wiring.test.js test/stack-playbook.test.js test/machine-output-flag.test.js test/stdout-flush.test.js test/format-determinism.test.js test/only-flag-channels.test.js test/confidence.test.js test/security-trend.test.js test/calibration-drift.test.js test/validator-metrics.test.js test/clustering-ir-taint-line.test.js test/concurrency-cwe.test.js test/compliance-mapping-liveness.test.js",
68
68
  "test:dataflow": "node --test test/container-taint.test.js test/stored-taint-families.test.js test/py-annotation-sources.test.js test/fn-reach.test.js test/deep-taint.test.js test/calibration.test.js test/holdout-eval.test.js test/cross-lang-meta.test.js test/cross-lang-queues.test.js test/phase5-xlang.test.js test/phase5-coverage.test.js test/phase6-taint.test.js test/llm-validator-consistency.test.js test/llm-validator-default-on.test.js test/llm-validator-preset.test.js test/parser-py-cst.test.js test/parser-cs-kt.test.js test/parser-cs-annotations.test.js test/parser-cs-control-flow.test.js test/parser-kt-control-flow.test.js test/parser-js-annotations.test.js test/parser-java-annotations.test.js test/parser-java-control-flow.test.js test/parser-java-calls.test.js test/parser-go.test.js test/parser-php-rb.test.js test/parser-php-control-flow.test.js test/interproc-k2.test.js test/proven-clean.test.js test/backward-default.test.js test/incremental-cache.test.js test/string-regex-lattice.test.js test/closure-capture.test.js test/points-to.test.js test/type-stubs.test.js test/soft-taint.test.js test/ifds.test.js test/symbolic-exec-proof.test.js test/ifds-summary-edges.test.js test/stub-aware-filter.test.js test/cross-repo.test.js test/proof-gate.test.js test/layer-recall.test.js test/java-taint-flow.test.js test/kt-taint-flow.test.js test/sanitizer-typed-flow.test.js test/sanitizer-gate-unit.test.js test/proof-safe.test.js test/collection-taint.test.js test/kcfa-context.test.js test/kcfa-callstring.test.js test/flow-parity.test.js test/callgraph-resolve.test.js test/import-reachability.test.js test/ir-stats.test.js test/parser-cpp.test.js test/parser-js-decorators.test.js test/parser-js-if-else-cfg.test.js test/cpp-integration.test.js test/engine-reconnect.test.js test/phase2-scoping.test.js test/engine-recall.test.js test/catalog-annotation-source.test.js test/catalog-cs-p1.test.js test/catalog-expanded.test.js test/builtin-summaries.test.js test/interproc-mutation-crash.test.js test/mutated-param-taint.test.js test/return-sink-check.test.js test/higher-order-inline-callback.test.js test/interproc-findings-discard.test.js test/runscan-deep-option.test.js test/summary-cache-convergence.test.js test/parser-rb-calls.test.js test/parser-rb-module-level.test.js test/balanced-call.test.js test/destructuring-taint.test.js test/computed-member-taint.test.js test/string-interpolation-taint.test.js test/dataflow-deadcode-severity.test.js test/catalog-dotted-callee-lookup.test.js test/builtin-summary-family-blind-kill.test.js test/deep-mode-annotator-pipeline.test.js test/receiver-type-and-nested-calls.test.js test/member-write-and-loop-taint.test.js test/parser-py-module-level.test.js test/parser-php-module-level.test.js test/r14b-module-level-e2e.test.js test/annotation-taint-engine.test.js test/interproc-nested-call-taint.test.js test/catalog-xpath-injection.test.js test/catalog-ldap-injection.test.js test/catalog-xxe.test.js test/catalog-response-splitting.test.js test/catalog-code-injection.test.js test/parser-rb-control-flow.test.js test/catalog-ruby-p1.test.js test/callee-receiver-taint.test.js test/catalog-command-injection-p4.test.js test/catalog-xss-p4.test.js test/catalog-path-ssrf-p3.test.js",
69
69
  "test:mcp": "node --test test/mcp.test.js test/mcp-audit.test.js test/audit-cli.test.js test/mcp-scratchpad.test.js test/mcp-offload.test.js test/sca-upgrade.test.js test/lsp-server.test.js",
70
70
  "test:report": "node --test test/junit.test.js test/ci.test.js test/poc-generator.test.js test/verifier.test.js test/verifier-target.test.js test/annotator-errors.test.js test/grader-calibration.test.js test/pr-delta-gate.test.js test/vex.test.js test/report-render.test.js",
package/src/engine.js CHANGED
@@ -35,6 +35,7 @@ import { scanLlmTradingAgent } from './sast/llm-trading-agent.js';
35
35
  import { scanMobileManifest } from './sast/mobile-manifest.js';
36
36
  import { scanQuarkusHardening } from './sast/quarkus-hardening.js';
37
37
  import { scanFastapiHardening } from './sast/fastapi-hardening.js';
38
+ import { isDeterministic } from './posture/deterministic.js';
38
39
  import { scanAuthZ } from './sast/authz.js';
39
40
  import { scanApiBrokenAuthz } from './sast/api-authz.js';
40
41
  import { scanTerraform } from './sast/iac-terraform.js';
@@ -8019,6 +8020,23 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8019
8020
  try { _GLOBAL_JAVA_TAINTED_METHODS = _buildGlobalJavaTaintedMethodIndex(fileContents); }
8020
8021
  catch { _GLOBAL_JAVA_TAINTED_METHODS = new Set(); }
8021
8022
  const _perFileTimeoutMs = parseInt(process.env.AGENTIC_SECURITY_PER_FILE_TIMEOUT_MS || '10000', 10);
8023
+ // Per-file wall-clock timings are the slowest-first performance view, and they
8024
+ // leaked straight into --deterministic output: the `ms` values differ run to
8025
+ // run, and because the list is SORTED BY those values the ORDER differs too.
8026
+ // Zeroing the numbers alone would not have been enough — the sort key would
8027
+ // have become constant and the resulting order arbitrary. Under deterministic
8028
+ // mode the timings are therefore reported as 0 and ordered by filename, so the
8029
+ // field keeps its shape (consumers still see the same 20 entries) without
8030
+ // carrying anything a clock decided.
8031
+ function _deterministicFileTimings(timings) {
8032
+ if (!isDeterministic()) return timings.sort((a, b) => b.ms - a.ms).slice(0, 20);
8033
+ return timings
8034
+ .slice()
8035
+ .sort((a, b) => String(a.file).localeCompare(String(b.file)))
8036
+ .slice(0, 20)
8037
+ .map(t => ({ ...t, ms: 0 }));
8038
+ }
8039
+
8022
8040
  const _fileTimings = [];
8023
8041
  let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0;
8024
8042
  const files=Object.keys(fileContents).filter(f=>(shouldScan(f) || isKubernetesManifest(f, fileContents[f])) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];
@@ -9516,7 +9534,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
9516
9534
  // seen when only N-of-those-candidates were actually analyzed.
9517
9535
  // checkpoint.total intentionally keeps files.length — that field means the
9518
9536
  // full candidate set for resume bookkeeping, a different, correct meaning.
9519
- const _scanMeta={filesScanned:Object.keys(fc).length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_fileTimings.sort((a,b)=>b.ms-a.ms).slice(0,20),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),resumed:_ckptResumed,total:files.length}};
9537
+ const _scanMeta={filesScanned:Object.keys(fc).length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_deterministicFileTimings(_fileTimings),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),resumed:_ckptResumed,total:files.length}};
9520
9538
  // R8: the scan completed, so the checkpoint has been fully consumed — remove
9521
9539
  // it. Anything that threw before this point leaves it in place to resume from.
9522
9540
  try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
@@ -118,7 +118,11 @@ export function loadFramework(scanRoot, id) {
118
118
  // `k8s-pod-privileged`; `nist-csf-2.json`/`hipaa-security-rule.json` map to
119
119
  // the compliance-side spelling `k8s-pod-security-privileged`, which no
120
120
  // detector ever emitted.
121
- const COMPLIANCE_FAMILY_ALIAS = {
121
+ export const COMPLIANCE_FAMILY_ALIAS = {
122
+ // ASVS spells it `sqli`; every detector emits `sql-injection` (or a
123
+ // language-prefixed variant, which the suffix rule below does NOT cover
124
+ // because the prefix is on the wrong end).
125
+ 'sqli': ['sql-injection', 'dart-sql-injection', 'laravel-sql-injection'],
122
126
  'auth-missing': ['broken-access-control', 'fastapi-missing-auth', 'springboot-missing-authz', 'laravel-missing-auth', 'quarkus-missing-authz'],
123
127
  'authz': ['broken-access-control', 'idor', 'springboot-missing-authz', 'quarkus-missing-authz'],
124
128
  'k8s-pod-security-privileged': ['k8s-pod-privileged'],
@@ -130,18 +134,63 @@ const COMPLIANCE_FAMILY_ALIAS = {
130
134
  // real detector to alias, and `mcp-audit.js`/`sca/dep-confusion.js` were
131
135
  // fixed at the SOURCE (they now set `family` explicitly) rather than
132
136
  // aliased, since the finding constructors themselves were the root cause.
133
- // Four references have no matching detector at all — not a naming
134
- // mismatch, a genuine coverage gap that would need a new rule, out of
135
- // scope for an alias table: `crypto-tls-version` (crypto-protocol.js checks
136
- // verify-disabled, not minimum-TLS-versionthe file's own header comment
137
- // even names the never-implemented `crypto-tls-min-version` rule id),
138
- // `nosql-injection` (referenced by cross-lang-meta.js's chain-detection
139
- // list, but no dedicated NoSQL-injection detector exists), `pii-exposure`
140
- // and `data-exposure` (both referenced only by consumers — threat-model-
141
- // auto.js's classifier and a Juliet-benchmark answer-key label
142
- // respectively with no producer). Every control mapped to one of these
143
- // four reads `manual`/`engine-gap` rather than a false `present`, which is
144
- // the safe failure mode this whole mechanism exists to guarantee.
137
+ //
138
+ // CORRECTION (measured, not read): this comment used to claim four families
139
+ // had no producer at all `crypto-tls-version`, `nosql-injection`,
140
+ // `pii-exposure` and `data-exposure`and that every control mapped to one
141
+ // of them therefore read `manual`/`engine-gap` rather than a false `present`.
142
+ // BOTH halves were wrong. Nothing implemented the engine-gap treatment (see
143
+ // COMPLIANCE_FAMILY_GAPS below), and all four families DO have producers:
144
+ // a sweep of 331 real scan roots (bench/family-producers/OBSERVED.json)
145
+ // observed crypto-tls-version, nosql-injection and data-exposure twice each,
146
+ // and `pii-exposure` is emitted by dataflow/privacy-taint.js:129.
147
+ //
148
+ // The lesson is recorded because it cost a wrong commit: a family is not a
149
+ // gap because a grep or a source comment says so. Several detectors pass
150
+ // `family` POSITIONALLY (`_shape(file, line, ruleId, vuln, fam, …)` in
151
+ // cloud-iam.js, crypto-protocol.js, k8s-admission.js, ml-supply-chain.js),
152
+ // so no textual search enumerates the real vocabulary. Only running the
153
+ // engine does.
154
+
155
+ // ...which the prose above ASSERTED but nothing enforced. A `family:` mapping
156
+ // with no detector behind it produces an empty bucket, and an empty bucket was
157
+ // reported as `✓ no open critical/high findings` — identical to a genuine pass.
158
+ // So the four known-unevidenceable families read as fully evidenced controls,
159
+ // which is precisely the outcome the comment says is avoided.
160
+ //
161
+ // Declaring them here makes the claim load-bearing: the evaluator consults this
162
+ // map (see the `family:` branch below) and caps such a control at `partial`
163
+ // with an explicit disclosure, and `test/compliance-mapping-liveness.test.js`
164
+ // fails if an entry loses its reason, duplicates an alias, or stops being
165
+ // referenced by any framework.
166
+ //
167
+ // Membership is deliberately conservative — these four come from the CMP-1
168
+ // audit recorded above, each confirmed to be referenced only by CONSUMER
169
+ // tables (attack-taxonomy, risk-dollars, threat-model classifiers) with no
170
+ // producing detector. A family is NOT added here merely because a static grep
171
+ // or a fixture sweep failed to observe it: several detectors pass `family`
172
+ // positionally (`_shape(file, line, ruleId, vuln, fam, …)` in cloud-iam.js,
173
+ // crypto-protocol.js, k8s-admission.js, ml-supply-chain.js), so neither source
174
+ // enumerates the real vocabulary on its own. Wrongly declaring a live family a
175
+ // gap would suppress a control that does work.
176
+ export const COMPLIANCE_FAMILY_GAPS = {
177
+ // Deliberately EMPTY. Every family currently mapped by a bundled framework
178
+ // has a producer, verified against bench/family-producers/OBSERVED.json.
179
+ //
180
+ // The mechanism below is retained because the hazard is real and structural:
181
+ // the evaluator resolves `family:X` against a Map keyed by finding.family, so
182
+ // a mapping nothing produces yields an empty bucket, and an empty bucket used
183
+ // to render as `✓ no open critical/high findings` — a pass nothing checked.
184
+ // An entry here caps such a control at `partial` with an explicit disclosure
185
+ // instead.
186
+ //
187
+ // Adding an entry requires EVIDENCE that no detector emits the family — a
188
+ // sweep that does not observe it is a lower bound, not proof (SCA families
189
+ // need network access, and some rules need shapes no corpus entry has).
190
+ // Wrongly declaring a live family a gap silently suppresses a control that
191
+ // works, which is the mirror image of the bug this prevents.
192
+ };
193
+
145
194
  export function evaluateFramework(scanRoot, fw, scan) {
146
195
  // CMP-2: last-scan.json (what this is actually handed in production) carries
147
196
  // findings across four separate channels — SAST (`findings`), secrets,
@@ -207,8 +256,36 @@ export function evaluateFramework(scanRoot, fw, scan) {
207
256
  // filtering is safe; findings with no subfamily set still count
208
257
  // (recall-preserving default — same precedent as relevance.js).
209
258
  const [fam, subfam] = m.slice('family:'.length).split(':');
259
+ // No detector produces this family, so its bucket is empty on EVERY
260
+ // scan. Reporting that as "no open findings" is a pass nothing checked
261
+ // — disclose it and cap the control at 'partial' (the same treatment a
262
+ // `rule:` mapping gets, and for the same reason: unverifiable is not
263
+ // evidence). Deliberately does NOT clear allCleared — the control is
264
+ // unknown, not failing.
265
+ if (COMPLIANCE_FAMILY_GAPS[fam]) {
266
+ obs.push(`⚠ ${fam}: no detector can evidence this control (engine-gap) — ${COMPLIANCE_FAMILY_GAPS[fam]}`);
267
+ anySignal = true;
268
+ hasUnverifiableMapping = true;
269
+ continue;
270
+ }
210
271
  const aliasFams = COMPLIANCE_FAMILY_ALIAS[fam] || [];
211
- const candidates = [fam, ...aliasFams].flatMap(k => families.get(k) || []);
272
+ // Several detectors emit the family as `<family>-<rule-slug>` — the
273
+ // observed vocabulary holds `prompt-injection-http-user-input-in-llm-`,
274
+ // `xpath-injection-query-built-via-string-c` and similar. This lookup
275
+ // used to be an exact Map key read, so `family:prompt-injection`
276
+ // matched none of them and the control read as evidenced whatever the
277
+ // scan found. That silenced LLM01 (Prompt Injection — the FIRST control
278
+ // of the OWASP LLM Top 10), ASVS V5.1 and NIST AI 600-1 MG-3.2-005.
279
+ //
280
+ // The `-` separator is load-bearing, not cosmetic: a bare substring or
281
+ // prefix test would let `nosql-injection` satisfy a `sql-injection`
282
+ // mapping, silently merging two different vulnerability classes. A test
283
+ // pins that boundary in the failing direction.
284
+ const bases = [fam, ...aliasFams];
285
+ const candidates = [];
286
+ for (const [key, list] of families) {
287
+ if (bases.some(b => key === b || key.startsWith(`${b}-`))) candidates.push(...list);
288
+ }
212
289
  const scoped = subfam ? candidates.filter(f => !f.subfamily || f.subfamily === subfam) : candidates;
213
290
  const open = scoped.filter(f => !f.intentSuppressed && !f.pastDecision && (f.severity === 'critical' || f.severity === 'high'));
214
291
  if (open.length) {
@@ -106,6 +106,13 @@ function grabBody(text, openBraceIdx) {
106
106
  return null;
107
107
  }
108
108
 
109
+ // Lock names come from a `\w+` capture so they cannot currently carry regex
110
+ // metacharacters, but the guards below interpolate them into a pattern — escape
111
+ // so that stays true if a capture ever widens.
112
+ function escapeRe(s) {
113
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
114
+ }
115
+
109
116
  function findMissedUnlocks(fn, lang) {
110
117
  const out = [];
111
118
  const p = PATTERNS[lang];
@@ -128,10 +135,32 @@ function findMissedUnlocks(fn, lang) {
128
135
  // Lock+unlock both present, but check that the function has a defer/
129
136
  // try-finally guarantee. Go: `defer`; Java/Py: try/finally; otherwise
130
137
  // early-return-before-unlock is a risk.
138
+ //
139
+ // The guard must accept a QUALIFIED receiver (`defer s.mu.Unlock()`,
140
+ // `with self.lock:`), not just a bare one. A mutex is usually a struct
141
+ // field or an instance attribute, so the qualified form is the common
142
+ // shape in real code — and the acquire patterns already matched it
143
+ // (`\b(\w+)\.Lock\(\)` captures `mu` from `s.mu.Lock()`). While the two
144
+ // halves disagreed, correct idiomatic code was the most likely to be
145
+ // reported: on a 12-entry Go sample this was the bulk of the module's
146
+ // output.
147
+ //
148
+ // It is also matched PER LOCK NAME rather than body-wide. A function
149
+ // holding two mutexes must not have its second lock cleared just because
150
+ // the first one is deferred.
151
+ // The receiver prefix is `[\w$.]*` — a single character class — rather
152
+ // than the more obvious `(?:[\w$]+\.)*`, which nests a quantifier inside
153
+ // a quantifier and is exactly the catastrophic-backtracking shape this
154
+ // project's own CWE-1333 detector flags (it caught this line in the
155
+ // self-scan). `\b` before the lock name is what keeps the flat form
156
+ // precise: without it, `[\w$.]*` would happily match `s.not` and let
157
+ // `defer s.notmu.Unlock()` guard a lock named `mu`.
158
+ const q = escapeRe(name);
131
159
  const guarded =
132
- (lang === 'go' && /defer\s+\w+\.Unlock\(\)/.test(fn.body)) ||
160
+ (lang === 'go' && new RegExp(`defer\\s+[\\w$.]*\\b${q}\\.Unlock\\(\\)`).test(fn.body)) ||
133
161
  (lang === 'java' && /try\s*\{[\s\S]*finally\s*\{[\s\S]*\.unlock\(\)/m.test(fn.body)) ||
134
- (lang === 'py' && (/with\s+\w+:/.test(fn.body) || /try\s*:[\s\S]*finally\s*:[\s\S]*\.release\(\)/m.test(fn.body)));
162
+ (lang === 'py' && (new RegExp(`with\\s+[^:\\n]*\\b${q}\\b[^:\\n]*:`).test(fn.body)
163
+ || /try\s*:[\s\S]*finally\s*:[\s\S]*\.release\(\)/m.test(fn.body)));
135
164
  if (!guarded && /\breturn\b/.test(fn.body)) {
136
165
  out.push({
137
166
  kind: 'unguarded-lock',
@@ -229,6 +258,12 @@ export function scanConcurrency(fileContents) {
229
258
  ? `Concurrency: ${fn.name}() acquires ${bug.lock} but no matching unlock`
230
259
  : `Concurrency: ${fn.name}() can return without releasing ${bug.lock}`,
231
260
  severity: 'medium',
261
+ // CWE-667 Improper Locking. The findings schema in CLAUDE.md requires a
262
+ // cwe on every finding, and these carried none — so they arrived in
263
+ // every CWE-keyed report as null and could never match an advisory
264
+ // label. Found while auditing the wrong-CWE bucket: 57% of findings
265
+ // on a Go/Ruby sample had no cwe at all.
266
+ cwe: 'CWE-667',
232
267
  family: 'concurrency-bug',
233
268
  confidence: 0.5,
234
269
  remediation: bug.remediation || 'Release the lock on every exit path (defer / try-finally / context manager).',
@@ -241,6 +276,7 @@ export function scanConcurrency(fileContents) {
241
276
  line: bug.startLine,
242
277
  vuln: `Concurrency: fire-and-forget async call in ${fn.name}() — result not awaited`,
243
278
  severity: 'low',
279
+ cwe: 'CWE-703', // Improper Check or Handling of Exceptional Conditions
244
280
  family: 'concurrency-bug',
245
281
  confidence: 0.4,
246
282
  remediation: 'Await the promise / call .get() on the future / use asyncio.gather.',
@@ -255,6 +291,7 @@ export function scanConcurrency(fileContents) {
255
291
  line: bug.startLineA,
256
292
  vuln: `Concurrency: potential deadlock — ${bug.order}`,
257
293
  severity: 'high',
294
+ cwe: 'CWE-833', // Deadlock
258
295
  family: 'concurrency-bug',
259
296
  confidence: 0.4,
260
297
  remediation: 'Acquire locks in a consistent global order across all call sites.',
@@ -39,6 +39,46 @@ function _keyPath() { return path.join(_keyDir(), 'scan-key'); }
39
39
  let _keySource = null;
40
40
  export function keyProvenance() { return _keySource || 'unresolved'; }
41
41
 
42
+ // `writeFileSync(fp, …, {flag:'wx'})` is exclusive-CREATE, not atomic
43
+ // create-with-content: it creates the file and THEN writes it. A concurrent
44
+ // process that opens the path in that window reads an empty or partial file,
45
+ // fails the hex check, and falls through to an ephemeral key — whose signatures
46
+ // verify nowhere, forever, indistinguishable from real tampering. That is the
47
+ // same failure the `wx` flag was added to prevent, just through a narrower
48
+ // window, and CI caught it: 1 of 8 concurrently-generated signatures failed to
49
+ // verify under the install key.
50
+ //
51
+ // Writing the full content to a temp file and hard-LINKING it into place closes
52
+ // the window. link(2) is atomic and fails with EEXIST rather than clobbering, so
53
+ // the destination path only ever appears with complete content, and the
54
+ // first-writer-wins guarantee is preserved. Some filesystems (and Windows in
55
+ // places) refuse hard links, so an unsupported link degrades to the previous
56
+ // exclusive-create behaviour rather than failing the scan.
57
+ function _publishKeyAtomically(fp, contents) {
58
+ const dir = _keyDir();
59
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
60
+ const tmp = path.join(dir, `scan-key.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
61
+ try {
62
+ fs.writeFileSync(tmp, contents, { mode: 0o600 });
63
+ try {
64
+ fs.linkSync(tmp, fp);
65
+ return 'created';
66
+ } catch (e) {
67
+ if (e.code === 'EEXIST') return 'exists';
68
+ // Hard links unsupported here — fall back, accepting the narrower race.
69
+ try {
70
+ fs.writeFileSync(fp, contents, { mode: 0o600, flag: 'wx' });
71
+ return 'created';
72
+ } catch (e2) {
73
+ if (e2.code === 'EEXIST') return 'exists';
74
+ throw e2;
75
+ }
76
+ }
77
+ } finally {
78
+ try { fs.unlinkSync(tmp); } catch { /* best effort */ }
79
+ }
80
+ }
81
+
42
82
  function _readOrGenerateKey() {
43
83
  const fromEnv = process.env.AGENTIC_SECURITY_HMAC_KEY;
44
84
  if (fromEnv && /^[0-9a-fA-F]{32,}$/.test(fromEnv.trim())) {
@@ -62,19 +102,30 @@ function _readOrGenerateKey() {
62
102
  // forever after, indistinguishable from real tampering.
63
103
  const buf = crypto.randomBytes(32);
64
104
  try {
65
- fs.mkdirSync(_keyDir(), { recursive: true, mode: 0o700 });
66
- fs.writeFileSync(fp, buf.toString('hex') + '\n', { mode: 0o600, flag: 'wx' });
67
- _keySource = 'per-install-new';
68
- return buf;
105
+ const outcome = _publishKeyAtomically(fp, buf.toString('hex') + '\n');
106
+ if (outcome === 'created') { _keySource = 'per-install-new'; return buf; }
107
+ // Another process published first — fall through to the EEXIST path and
108
+ // adopt ITS key, exactly as before.
109
+ const e = new Error('key already published'); e.code = 'EEXIST'; throw e;
69
110
  } catch (e) {
70
111
  if (e.code === 'EEXIST') {
71
112
  // Another process won the race and persisted its key first — use
72
113
  // THAT key instead of the one we generated, or we'd return a key
73
114
  // that matches nothing on disk.
74
- try {
75
- const hex = fs.readFileSync(fp, 'utf8').trim();
76
- if (/^[0-9a-fA-F]{32,}$/.test(hex)) { _keySource = 'per-install'; return Buffer.from(hex, 'hex'); }
77
- } catch { /* fall through to ephemeral */ }
115
+ // Bounded retry, defence-in-depth. With the atomic link publish above the
116
+ // winner's key is complete the instant the path exists, so one read is
117
+ // enough. It is NOT enough when the link fell back to exclusive-create
118
+ // (filesystems without hard links), or when an older version of this file
119
+ // left a torn key behind — there the content can still be arriving. A few
120
+ // short retries cost nothing and the alternative is an ephemeral key whose
121
+ // signatures never verify again.
122
+ for (let attempt = 0; attempt < 5; attempt++) {
123
+ try {
124
+ const hex = fs.readFileSync(fp, 'utf8').trim();
125
+ if (/^[0-9a-fA-F]{32,}$/.test(hex)) { _keySource = 'per-install'; return Buffer.from(hex, 'hex'); }
126
+ } catch { /* not readable yet */ }
127
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2); } catch { /* no sleep available */ }
128
+ }
78
129
  }
79
130
  // Could not persist (or the winner's key was unreadable/malformed) —
80
131
  // this key lives for this process only, so nothing signed with it will
@@ -23,8 +23,24 @@
23
23
  // - Per-language template variants beyond the primary host language.
24
24
  // Those land in P1.2.
25
25
 
26
+ import * as crypto from 'node:crypto';
26
27
  import { CWE_TO_FAMILY, FAMILY_TO_PRIMARY_CWE } from './poc-cwe-map.js';
27
28
 
29
+ // The XSS marker was `Math.random()`, evaluated at GENERATION time, so the
30
+ // same finding produced a different PoC on every scan and `--deterministic`
31
+ // could not make the JSON report byte-identical. (The XXE sentinel further
32
+ // down also calls Math.random(), but that call is literal text inside the
33
+ // EMITTED PoC — it runs when the PoC runs, so the artifact is already stable.
34
+ // Leave it alone.)
35
+ //
36
+ // Derived from the finding instead: still distinct per finding, which is what
37
+ // the marker is for — a token that will not collide with content already in
38
+ // the response — but now reproducible.
39
+ function _stableToken(f, n = 6) {
40
+ const seed = String((f && (f.stableId || f.id)) || `${f && f.file}:${f && f.line}`);
41
+ return crypto.createHash('sha256').update(seed).digest('hex').slice(0, n);
42
+ }
43
+
28
44
  // ─── Template selectors ─────────────────────────────────────────────────────
29
45
  //
30
46
  // Each entry: { cwe, family, vulnContains, lang, render(finding, ctx) → code }
@@ -64,7 +80,7 @@ const TEMPLATES = [
64
80
  kind: 'http-payload',
65
81
  render: (f, ctx) => _httpPocNode(ctx, {
66
82
  header: 'Demonstrates reflected XSS by checking the script payload appears unencoded.',
67
- payload: `"><script>__POC_XSS_${Math.random().toString(36).slice(2, 8)}</script>`,
83
+ payload: `"><script>__POC_XSS_${_stableToken(f)}</script>`,
68
84
  expect: 'response body contains the literal <script> payload (proves no HTML encoding)',
69
85
  }),
70
86
  },
@@ -7,6 +7,7 @@
7
7
  // SPDX 2.3 schema reference: https://spdx.github.io/spdx-spec/v2.3/
8
8
 
9
9
  import * as crypto from 'node:crypto';
10
+ import { isDeterministic } from './deterministic.js';
10
11
 
11
12
  function _purl(c) {
12
13
  if (c.purl) return c.purl;
@@ -21,10 +22,39 @@ function _bomRef(c) {
21
22
  return `${c.ecosystem || 'pkg'}:${c.name}@${c.version}`;
22
23
  }
23
24
 
25
+ // CycloneDX `serialNumber` and SPDX `documentNamespace` are both required to
26
+ // identify a document, and both were minted with crypto.randomUUID() — so two
27
+ // scans of identical input produced different bytes, and `--deterministic` did
28
+ // not actually make an SBOM reproducible. An attestation over an SBOM is only
29
+ // meaningful if the SBOM can be regenerated and compared.
30
+ //
31
+ // Under --deterministic the identifier is derived from the document's own
32
+ // content instead of randomness. That preserves what the identifier is FOR:
33
+ // different content still yields a different id, while identical content
34
+ // yields an identical one — the standard reproducible-build treatment. Outside
35
+ // deterministic mode the random UUID is unchanged, so ordinary scans keep
36
+ // per-run-unique document ids.
37
+ function _stableUuidFrom(seed) {
38
+ const h = crypto.createHash('sha256').update(String(seed)).digest('hex');
39
+ // Shape the digest as a v4-looking UUID: the version/variant nibbles are set
40
+ // so consumers that validate the format still accept it.
41
+ return [
42
+ h.slice(0, 8),
43
+ h.slice(8, 12),
44
+ `4${h.slice(13, 16)}`,
45
+ `${((parseInt(h[16], 16) & 0x3) | 0x8).toString(16)}${h.slice(17, 20)}`,
46
+ h.slice(20, 32),
47
+ ].join('-');
48
+ }
49
+
50
+ function _documentUuid(seed) {
51
+ return isDeterministic() ? _stableUuidFrom(seed) : crypto.randomUUID();
52
+ }
53
+
24
54
  export function toCycloneDX(scan, meta = {}) {
25
55
  const components = scan.components || [];
26
56
  const supplyChain = (scan.supplyChain || []).filter(s => s.type === 'vulnerable_dep');
27
- const serialNumber = `urn:uuid:${crypto.randomUUID()}`;
57
+ const serialNumber = `urn:uuid:${_documentUuid(JSON.stringify(components.map(_bomRef)))}`;
28
58
 
29
59
  const cdxComponents = components.map(c => ({
30
60
  type: 'library',
@@ -36,8 +66,12 @@ export function toCycloneDX(scan, meta = {}) {
36
66
  ...(c.scope ? { scope: c.scope === 'dev' ? 'optional' : 'required' } : {}),
37
67
  }));
38
68
 
39
- const vulnerabilities = supplyChain.map(s => ({
40
- 'bom-ref': `${_bomRef({ ecosystem: s.ecosystem, name: s.name, version: s.version })}#${s.osvId || s.advisory || crypto.randomUUID()}`,
69
+ const vulnerabilities = supplyChain.map((s, i) => ({
70
+ // The last-resort id was crypto.randomUUID(), which reintroduced
71
+ // per-run drift for any advisory carrying neither an osvId nor an
72
+ // advisory string. Index within the (already deterministically sorted)
73
+ // supplyChain array identifies it just as well and is reproducible.
74
+ 'bom-ref': `${_bomRef({ ecosystem: s.ecosystem, name: s.name, version: s.version })}#${s.osvId || s.advisory || `unidentified-${i}`}`,
41
75
  id: s.osvId || (s.cveAliases || [])[0] || s.advisory,
42
76
  source: { name: 'OSV.dev', url: `https://osv.dev/vulnerability/${s.osvId || ''}` },
43
77
  references: (s.cveAliases || []).map(cve => ({ id: cve, source: { name: 'NVD' } })),
@@ -72,7 +106,7 @@ export function toCycloneDX(scan, meta = {}) {
72
106
  export function toSPDX(scan, meta = {}) {
73
107
  const components = scan.components || [];
74
108
  const supplyChain = (scan.supplyChain || []).filter(s => s.type === 'vulnerable_dep');
75
- const docNamespace = `https://agentic-security.local/spdx/${crypto.randomUUID()}`;
109
+ const docNamespace = `https://agentic-security.local/spdx/${_documentUuid(JSON.stringify(components.map(_bomRef)))}`;
76
110
  const ts = meta.startedAt || new Date().toISOString();
77
111
 
78
112
  const packages = components.map((c, i) => ({