@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.
- package/CHANGELOG.md +73 -0
- package/bin/agentic-security.js +40 -11
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +2 -2
- package/src/engine.js +19 -1
- package/src/posture/auditor-walkthrough.js +91 -14
- package/src/posture/concurrency-checker.js +39 -2
- package/src/posture/integrity.js +59 -8
- package/src/posture/poc-generator.js +17 -1
- package/src/posture/sbom.js +38 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
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
|
-
|
|
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' &&
|
|
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' && (
|
|
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.',
|
package/src/posture/integrity.js
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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_${
|
|
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
|
},
|
package/src/posture/sbom.js
CHANGED
|
@@ -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:${
|
|
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
|
-
|
|
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/${
|
|
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) => ({
|