@clear-capabilities/agentic-security-scanner 0.157.2 → 0.159.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 +49 -0
- package/dist/1919.index.js +5 -2
- package/dist/2862.index.js +29 -9
- package/dist/4171.index.js +2 -2
- package/dist/{8551.index.js → 7033.index.js} +430 -22
- package/dist/9560.index.js +3 -1
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -5
- package/src/dataflow/engine.js +23 -0
- package/src/engine.js +7 -1
- package/src/language/CLAUDE.md +53 -1
- package/src/language/assurance.js +21 -4
- package/src/language/context.js +2 -0
- package/src/language/cvss.js +91 -0
- package/src/language/fix-lifecycle.js +114 -21
- package/src/language/haskell-adapter.js +4 -3
- package/src/language/haskell-advisory-feed.js +194 -0
- package/src/language/haskell-boundaries.js +220 -0
- package/src/language/haskell-cpp.js +460 -0
- package/src/language/haskell-ir.js +6 -3
- package/src/language/haskell-models.js +12 -5
- package/src/language/haskell-parser.js +122 -38
- package/src/language/haskell-sca.js +28 -10
- package/src/language/haskell-security-rules.js +1 -1
- package/src/language/haskell-supply.js +70 -8
- package/src/language/haskell-web.js +143 -10
- package/src/language/nix-advisory-feed.js +275 -0
- package/src/language/nix-fix.js +162 -24
- package/src/language/nix-ir.js +16 -12
- package/src/language/nix-sca.js +80 -15
- package/src/language/nixos-eval-forms.js +162 -0
- package/src/language/nixos-hardening.js +37 -2
- package/src/language/nixos-module-resolver.js +359 -39
- package/src/language/nixos-option-catalog.js +1 -0
- package/src/language/resolved-pass.js +65 -3
- package/src/lsp/server.js +5 -2
- package/src/mcp/tools.js +3 -1
- package/src/posture/fix-history.js +30 -3
- package/src/report/index.js +4 -1
- package/dist/1310.index.js +0 -3161
- package/dist/2376.index.js +0 -3151
- package/dist/3526.index.js +0 -1019
- package/dist/7310.index.js +0 -520
- package/dist/8520.index.js +0 -13
- package/dist/9091.index.js +0 -3135
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
6a0bcec455607538bd4485ce3b9cd5a266f51daf636b0ef6260cdc3bacf01776 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.159.0",
|
|
4
4
|
"description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -79,14 +79,15 @@
|
|
|
79
79
|
"test:lineage": "node --test test/lineage/schema.test.js test/lineage/ids.test.js test/lineage/protection.test.js test/lineage/classification.test.js test/lineage/validate.test.js test/lineage/json-schema-parity.test.js test/lineage/flagship-fixture.test.js test/lineage/flagship-fixture-semantics.test.js test/lineage/field-identity.test.js test/lineage/engine-expr-resolver.test.js test/lineage/engine-walker.test.js test/lineage/engine-integration.test.js test/lineage/engine-receiver-identity.test.js test/lineage/summaries.test.js test/lineage/driver.test.js test/lineage/engine-provenance.test.js test/lineage/engine-provenance-interprocedural.test.js test/lineage/path-store.test.js test/lineage/path-query.test.js test/lineage/flow-grade.test.js test/lineage/source-registry.test.js test/lineage/sink-registry.test.js test/lineage/transform-catalog.test.js test/lineage/handling-analyzer.test.js test/lineage/registry-real-code.test.js test/lineage/source-seeding.test.js test/lineage/graph-builder.test.js test/lineage/coverage.test.js test/lineage/resolve-destination.test.js test/lineage/queue-detail.test.js test/lineage/index.test.js test/lineage/obligation-mapping.test.js test/lineage/obligation-predicates.test.js test/lineage/obligation-predicates-walkthrough.test.js test/lineage/transit-protection.test.js test/lineage/at-rest-protection.test.js test/lineage/protection-summary.test.js test/lineage/ac01-multi-sink.test.js test/lineage/policy-verdict.test.js test/lineage/governance-refs.test.js test/lineage/export-privacy.test.js test/lineage/export-json.test.js test/lineage/export-csv.test.js test/lineage/decision-story.test.js test/lineage/export-briefing.test.js test/lineage/graph-snapshot.test.js test/lineage/graph-diff.test.js test/lineage/drift-policy.test.js test/lineage/recipient-profile.test.js test/lineage/recipient-registry.test.js test/lineage/recipient-wiring.test.js test/lineage/redact-graph.test.js test/lineage-scan-wiring.test.js test/lineage-artifact-write.test.js test/bench-data-lineage-runner.test.js test/bundle-frontend.test.js test/bundle-frontend-golden.test.js test/generate-html-report.test.js test/export-image.test.js test/lineage/scenario.test.js test/lineage/scenario-engine.test.js test/lineage/scenario-diff.test.js test/lineage/scenario-no-obligation-wiring.test.js test/lineage/impact-assessment.test.js test/lineage/impact-engine.test.js test/lineage/impact-no-obligation-wiring.test.js test/lineage/governance-edit.test.js test/lineage/remediation.test.js test/lineage/runtime-observation.test.js test/lineage/observation-correlation.test.js test/lineage/observation-adapters.test.js test/lineage/observation-store.test.js test/lineage/runtime-corroboration-wiring.test.js test/lineage/cross-repo-link.test.js test/lineage/federation-loader.test.js test/lineage/cross-repo-link-wiring.test.js",
|
|
80
80
|
"test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js test/proof-corpus-lib.test.js test/proof-corpus-runner.test.js",
|
|
81
81
|
"test:server": "node --test test/server/graph-loader.test.js test/server/security.test.js test/server/routes.test.js test/server/http-server.test.js test/server/cmd-explore.test.js test/server/cmd-dataflow-export.test.js test/server/static-assets.test.js test/server/dataflow-scenario-cli.test.js test/server/dataflow-impact-cli.test.js",
|
|
82
|
-
"test:haskell": "node --test test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js",
|
|
83
|
-
"test:nix": "node --test test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js",
|
|
84
|
-
"test:language": "node --test test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/language-gap-fixes.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js test/language/language-trust-boundary.test.js",
|
|
82
|
+
"test:haskell": "node --test test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-sca-severity.test.js test/haskell/haskell-web-guards.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js test/haskell/haskell-boundaries.test.js test/haskell/haskell-live-feed-scan.test.js",
|
|
83
|
+
"test:nix": "node --test test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-sca-severity.test.js test/nix/nix-haskell-closure-names.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js test/nix/nix-fix-multifile.test.js test/nix/nixos-evaluator-forms.test.js test/nix/nix-advisory-feed.test.js",
|
|
84
|
+
"test:language": "node --test test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/language-gap-fixes.test.js test/language/language-gap-fixes-2.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js test/language/language-trust-boundary.test.js",
|
|
85
85
|
"test:language-stress": "node --test --test-concurrency=1 test/language/language-stress-offline.test.js",
|
|
86
86
|
"test:language-tools": "node --test test/haskell/haskell-web-auth.test.js test/haskell/haskell-support-gate.test.js",
|
|
87
87
|
"bench:language-support:check": "node ../bench/language-support/check.mjs",
|
|
88
|
+
"bench:live-feed": "node ../bench/live-feed/run.mjs",
|
|
88
89
|
"bench:language-support:perf": "node ../bench/language-support/perf.mjs",
|
|
89
|
-
"test:lifecycle": "node --test test/pre-bash-guard.test.js test/lsp-protocol-smoke.test.js test/ide-surfaces.test.js test/detector-liveness.test.js test/bench-watchdog.test.js test/ci-parity.test.js test/dead-code.test.js test/no-dead-modules.test.js test/no-orphan-scripts.test.js test/no-stray-state.test.js test/tree-integrity.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js test/dispatch-pre-tool.test.js test/dispatch-user-prompt.test.js test/session-start-model-capture.test.js test/sandbox.test.js test/sandbox-escape.test.js test/ci-templates.test.js test/agent-mcp-tools.test.js test/triage-command.test.js test/no-orphan-tests.test.js test/supply-command.test.js test/posture-command.test.js test/labs-command.test.js test/compliance-command.test.js test/check-doc-drift.test.js test/dist-chunks-tracked.test.js && node ../scripts/lint-command-descriptions.mjs",
|
|
90
|
+
"test:lifecycle": "node --test test/ci-hang-protection.test.js test/pre-bash-guard.test.js test/lsp-protocol-smoke.test.js test/ide-surfaces.test.js test/detector-liveness.test.js test/bench-watchdog.test.js test/ci-parity.test.js test/dead-code.test.js test/no-dead-modules.test.js test/no-orphan-scripts.test.js test/no-stray-state.test.js test/tree-integrity.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js test/dispatch-pre-tool.test.js test/dispatch-user-prompt.test.js test/session-start-model-capture.test.js test/sandbox.test.js test/sandbox-escape.test.js test/ci-templates.test.js test/agent-mcp-tools.test.js test/triage-command.test.js test/no-orphan-tests.test.js test/supply-command.test.js test/posture-command.test.js test/labs-command.test.js test/compliance-command.test.js test/check-doc-drift.test.js test/dist-chunks-tracked.test.js && node ../scripts/lint-command-descriptions.mjs",
|
|
90
91
|
"test:eval": "node --test test/independent-eval.test.js test/realworld-recall.test.js",
|
|
91
92
|
"test:discovery": "node --test test/discovery-partition.test.js test/discovery-lenses.test.js test/discovery-hunter.test.js test/discovery-confirm.test.js test/discovery-disprove.test.js test/discovery-judge.test.js test/discovery-run.test.js test/discovery-wiring.test.js test/discovery-llm-invoke.test.js test/local-only-mode.test.js",
|
|
92
93
|
"eval:independent": "node ../bench/independent-eval/runner.mjs",
|
|
@@ -186,6 +187,7 @@
|
|
|
186
187
|
"release:check": "node ../scripts/release-check.mjs",
|
|
187
188
|
"release:check:fast": "node ../scripts/release-check.mjs --fast",
|
|
188
189
|
"gate:prepush": "node ../scripts/pre-push-gate.mjs",
|
|
190
|
+
"gate:trace": "node ../scripts/gate-trace-reads.mjs",
|
|
189
191
|
"gate:prepush:install": "node ../scripts/pre-push-gate.mjs --install-hook",
|
|
190
192
|
"bench:independent": "node ../bench/independent/runner.mjs",
|
|
191
193
|
"bench:independent:fetch": "node ../bench/independent/fetch.mjs",
|
package/src/dataflow/engine.js
CHANGED
|
@@ -1148,6 +1148,7 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
|
|
|
1148
1148
|
// Haskell: two sinks of the same kind on one line (`if b then f (clean x) else f x`) are distinct
|
|
1149
1149
|
// sites; without a site key they share an id and one silently replaces the other.
|
|
1150
1150
|
...(/\.(?:hs|lhs)$/i.test(_currentFile || '') ? { siteKey: _siteKey(taintedArgExpr) } : {}),
|
|
1151
|
+
...(/\.(?:hs|lhs)$/i.test(_currentFile || '') && taintedArgExpr && taintedArgExpr.kind === 'call' ? { _argCallee: taintedArgExpr.callee } : {}),
|
|
1151
1152
|
kind: 'taint',
|
|
1152
1153
|
sinkId: e.id,
|
|
1153
1154
|
vuln: e.vuln?.name || 'Tainted Sink',
|
|
@@ -1783,6 +1784,26 @@ function step(node, stateIn, callContext) {
|
|
|
1783
1784
|
// state, never into the SummaryCache key (that stays exactly what the caller
|
|
1784
1785
|
// supplied). Returns the ORIGINAL Set unchanged when there's nothing to add,
|
|
1785
1786
|
// so callers that never touch annotation-shaped params pay zero extra cost.
|
|
1787
|
+
// Haskell: a sink whose tainted argument IS a call to another sink of the same CWE on the same line is one flaw reported twice
|
|
1788
|
+
// (`readCreateProcess (shell cmd)`: `shell` builds the command, `readCreateProcess` runs it). The INNER sink is kept, because it is
|
|
1789
|
+
// where the tainted text enters the command; the outer one is recorded on it, never dropped silently. Only a nested pair collapses:
|
|
1790
|
+
// two independent sinks on one line (`if b then f (clean x) else f x`) have different arguments and stay two findings, so a
|
|
1791
|
+
// discharged sink still cannot absorb its unprotected sibling.
|
|
1792
|
+
function _collapseNestedHsSinks(all) {
|
|
1793
|
+
const base = (c) => String(c && typeof c === 'object' ? (c.name || c.text || '') : (c ?? '')).split('.').pop();
|
|
1794
|
+
for (let i = all.length - 1; i >= 0; i--) {
|
|
1795
|
+
const f = all[i];
|
|
1796
|
+
if (f._argCallee === undefined || !/\.(?:hs|lhs)$/i.test(f.file || '')) continue;
|
|
1797
|
+
const want = base(f._argCallee);
|
|
1798
|
+
const inner = want && all.find((g) => g !== f && g.file === f.file && g.line === f.line && g.cwe === f.cwe && g.callee !== undefined && base(g.callee) === want);
|
|
1799
|
+
if (!inner) continue;
|
|
1800
|
+
inner.dedupedVulns = [...new Set([...(inner.dedupedVulns || []), f.vuln])];
|
|
1801
|
+
inner.alsoSink = [...new Set([...(inner.alsoSink || []), base(f.callee)])];
|
|
1802
|
+
all.splice(i, 1);
|
|
1803
|
+
}
|
|
1804
|
+
for (const f of all) if (f._argCallee !== undefined) delete f._argCallee;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1786
1807
|
// Short stable fingerprint of an argument expression (ignores line numbers and IR bookkeeping).
|
|
1787
1808
|
function _siteKey(expr) {
|
|
1788
1809
|
let h = 5381;
|
|
@@ -2575,6 +2596,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
2575
2596
|
if (dg[f.severity]) f.severity = dg[f.severity];
|
|
2576
2597
|
}
|
|
2577
2598
|
Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
|
|
2599
|
+
_collapseNestedHsSinks(all);
|
|
2578
2600
|
return all;
|
|
2579
2601
|
|
|
2580
2602
|
// Dedup + map a raw findings array (from analyzeFunction's callContext)
|
|
@@ -2654,6 +2676,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
2654
2676
|
// rebuilding the SMT path-feasibility mechanism (next-gen taint
|
|
2655
2677
|
// capability #5) to depend on a REAL slice for a sound proof.
|
|
2656
2678
|
...(f.argIndex !== undefined ? { argIndex: f.argIndex } : {}),
|
|
2679
|
+
...(f._argCallee !== undefined ? { _argCallee: f._argCallee } : {}),
|
|
2657
2680
|
// callee: kept as plain `callee` (not underscore-prefixed) to match
|
|
2658
2681
|
// backward.js's own contract, which reads `f.callee` on both real and
|
|
2659
2682
|
// fake-fixture findings throughout its module and test suite. It is
|
package/src/engine.js
CHANGED
|
@@ -8436,6 +8436,9 @@ function genHAR(routes,sources){const baseUrl="http://localhost:3000";const entr
|
|
|
8436
8436
|
|
|
8437
8437
|
async function queryRegistries(components){
|
|
8438
8438
|
const infoMap=new Map();
|
|
8439
|
+
// Registry metadata (latest version, deprecation, age) is optional enrichment. Offline mode (--no-network, --deterministic, AGENTIC_SECURITY_OFFLINE=1)
|
|
8440
|
+
// must not touch the network for it: found by the live Hackage feed bench, where an "offline" scan still fetched registry.npmjs.org/jquery.
|
|
8441
|
+
if(process.env.AGENTIC_SECURITY_OFFLINE==='1')return infoMap;
|
|
8439
8442
|
const npmNames=[...new Set(components.filter(c=>c.ecosystem==='npm').map(c=>c.name))];
|
|
8440
8443
|
const pypiNames=[...new Set(components.filter(c=>c.ecosystem==='pypi').map(c=>c.name))];
|
|
8441
8444
|
const CHUNK=8;
|
|
@@ -9228,6 +9231,8 @@ function _deterministicFileTimings(timings) {
|
|
|
9228
9231
|
const annotatedComponents=components.map(c=>{const key=`${c.ecosystem}:${c.name}:${c.version}`;const vulns=vulnsByKey[key]||[];const riKey=c.ecosystem==='maven'&&c.group?`maven:${c.group}/${c.name}`:`${c.ecosystem}:${c.name}`;const ri=registryInfo.get(riKey)||{};const latestVersion=ri.latestVersion||'';const vd=(ri.versions||{})[c.version]||{};const isDeprecated=typeof vd.deprecated==='string'&&vd.deprecated.length>0;const deprecationMessage=isDeprecated?vd.deprecated:'';const isOutdated=!isDeprecated&&typeof vd.outdated==='string'&&vd.outdated.length>0;const outdatedMessage=isOutdated?vd.outdated:'';const license=ri.license||vd.license||'';return{...c,vulns,hasVulns:vulns.length>0,hasAttackPath:attackResult.flagged.has(key),attackPaths:attackResult.pathsByKey.get(key)||[],latestVersion,isDeprecated,deprecationMessage,isOutdated,outdatedMessage,license};});
|
|
9229
9232
|
// X-010: Hackage and Nix components, dependency edges and target provenance. Kept beside (not inside) the ordinary
|
|
9230
9233
|
// component list so the existing consumers are untouched; the BOM emitters and the SBOM diff read it explicitly.
|
|
9234
|
+
try{if(process.env.AGENTIC_SECURITY_HACKAGE_ADVISORIES_LIVE==='1'){const{prefetchHackageFeed}=await import('./language/haskell-supply.js');const{resolvedHackageComponents,nixHaskellPackageNames}=await import('./language/resolved-pass.js');let _rc=[],_nx=[];try{_rc=resolvedHackageComponents(allFileContents).components||[];}catch(_){}try{_nx=nixHaskellPackageNames(allFileContents);}catch(_){}await prefetchHackageFeed(allFileContents,{resolvedComponents:_rc,extraNames:_nx});}}catch(_){/* the live feed is optional: a failure leaves the scan on whatever snapshot exists */}
|
|
9235
|
+
try{if(process.env.AGENTIC_SECURITY_NIX_ADVISORIES_LIVE==='1'){const{prefetchNixAdvisoryFeed}=await import('./language/resolved-pass.js');await prefetchNixAdvisoryFeed(allFileContents);}}catch(_){/* the live feed is optional: a failure leaves the scan on whatever snapshot exists */}
|
|
9231
9236
|
let _languageBom=null;try{if(Object.keys(allFileContents).some(f=>/\.(?:l?hs|nix)$|\.cabal$|(?:^|\/)(?:cabal\.project(?:\.freeze)?|package\.yaml|stack\.yaml(?:\.lock)?|flake\.lock)$/i.test(f))){const{languageBom}=await import('./language/bom.js');const{resolvedHaskellGraph,nixClosureOf}=await import('./language/resolved-pass.js');const _rg=resolvedHaskellGraph(allFileContents);const _nc=nixClosureOf(allFileContents);_languageBom=languageBom(allFileContents,{...(_rg?{resolved:_rg.graph}:{}),...(_nc&&!_nc.refused.length?{closure:_nc.closure}:{})});}}catch(_){_languageBom=null;}
|
|
9232
9237
|
aF.push(...(runDetector(_detectorErrors,'<project>','scanDbTaintCrossFile',()=>scanDbTaintCrossFile(fc))||[]));
|
|
9233
9238
|
aF.push(...(runDetector(_detectorErrors,'<project>','scanCsharpCrossFile',()=>scanCsharpCrossFile(fc))||[]));
|
|
@@ -11030,6 +11035,7 @@ function _deterministicFileTimings(timings) {
|
|
|
11030
11035
|
if (Object.keys(allFileContents).some(f => /\.(?:l?hs|hs-boot|hsc|nix)$|\.cabal$|(?:^|\/)(?:cabal\.project|package\.yaml|stack\.yaml)/i.test(f))) {
|
|
11031
11036
|
const { assessLanguageAssurance } = await import('./language/assurance.js');
|
|
11032
11037
|
const { configuredAdvisoryDb } = await import('./language/haskell-supply.js');
|
|
11038
|
+
const { liveFeedOptionalState } = await import('./language/haskell-advisory-feed.js');
|
|
11033
11039
|
const env = process.env;
|
|
11034
11040
|
const { resolvedHackageComponents, analyzeNixClosure, runSelectedNixEval } = await import('./language/resolved-pass.js');
|
|
11035
11041
|
let _languageSupplyGaps = [];
|
|
@@ -11043,7 +11049,7 @@ function _deterministicFileTimings(timings) {
|
|
|
11043
11049
|
sizeSkipped,
|
|
11044
11050
|
supplyGaps: _languageSupplyGaps,
|
|
11045
11051
|
licenseUnavailable: ((_languageBom && _languageBom.components) || []).filter((c) => c && !c.license && !(Array.isArray(c.licenses) && c.licenses.length)).length,
|
|
11046
|
-
optional: { 'nix-eval': { selected: env.AGENTIC_SECURITY_NIX_EVAL === '1', result: _nixEvalResult }, 'cabal-plan': { selected: false }, 'hackage-live':
|
|
11052
|
+
optional: { 'nix-eval': { selected: env.AGENTIC_SECURITY_NIX_EVAL === '1', result: _nixEvalResult }, 'cabal-plan': { selected: false }, 'hackage-live': liveFeedOptionalState(env)},
|
|
11047
11053
|
})).languageCoverage;
|
|
11048
11054
|
}
|
|
11049
11055
|
} catch (e) { _languageCoverage = { totals: null, byKind: null, conditions: [`language assurance could not be computed: ${String((e && e.message) || e)}`] }; }
|
package/src/language/CLAUDE.md
CHANGED
|
@@ -26,6 +26,36 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
|
|
|
26
26
|
outside the project root is refused (`fix-lifecycle.js` `isRootRelativePath`).
|
|
27
27
|
- **Secrets never leave redacted.** `secrets.js` `redactLanguageSecrets` joins split literals before masking; egress and model
|
|
28
28
|
prompts use it. Credential values are never written to any output or state file.
|
|
29
|
+
- **The advisory feed is opt-in and records coverage.** `haskell-advisory-feed.js` touches the network only when
|
|
30
|
+
`AGENTIC_SECURITY_HACKAGE_ADVISORIES_LIVE=1` and never under `AGENTIC_SECURITY_OFFLINE`. Its snapshot lists the packages it actually
|
|
31
|
+
queried (`covered`); a package it did not cover is `feed-incomplete` and a stale one `feed-stale`, in BOTH matchers
|
|
32
|
+
(`evaluateComponents` and `nix-sca.js`). Any new consumer of an `AdvisoryDb` must check `db.coverage(name)` before saying "no advisory".
|
|
33
|
+
The snapshot lives in the operator configuration directory, never the project (`trusted-inputs.js`).
|
|
34
|
+
- **Auth guards must stop the handler.** In `haskell-web.js` a Scotty `status 40x` only sets the code, so both the named-guard path (`guardSummary`) and the inline path (`handlerFacts`) need a halting call (`finish`, `raise`, `raiseStatus`, `redirect`; `finish` is an IR identifier with no line of its own). WAI, Servant and Yesod inline guards order the credential READ against the first sensitive call, because a tail-position rejection reports the enclosing line (a disclosed gap, pinned in `test/haskell/haskell-web-guards.test.js`). A WAI `responseLBS` rejection counts only when handed back (`respond`/`return`).
|
|
35
|
+
- **Severity is the advisory's own** (`cvss.js`): CVSS v3.x base score, else a named `database_specific.severity`, else `medium` with the basis stated. Never guess from an unparseable or v4 vector.
|
|
36
|
+
- **Option fixes work on the effective configuration, across files.** `nix-fix.js` edits EVERY contributing definition of an option
|
|
37
|
+
(all winners at the winning priority, plus conditional branches holding the weak literal), never one line: editing one of two
|
|
38
|
+
equal-priority winners turns a finding into a module-system conflict that a rescan alone reports as "fixed". Verification is
|
|
39
|
+
the `effective` gate (`makeEffectiveCheck`: resolve the option again over the patched tree). A plan may carry `edits` (several
|
|
40
|
+
files); `runFixLifecycle` applies them through `writeManyWithBackup` (checks everything and backs up everything before the
|
|
41
|
+
first write, rolls back on a failed write) and records one history entry per file sharing `languageGroupId`; `undoFix` and
|
|
42
|
+
`posture/fix-history.js` `undoLast`/`revertEntryById` restore a group as a unit. A winner outside the root or not a single
|
|
43
|
+
literal is refused with an `override` suggestion (`overrideSuggestion`), never an automatic edit. Any new consumer of a fix
|
|
44
|
+
plan (LSP, MCP, CLI) must honour `plan.edits`: `apply_fix` writes one file, so a multi-file fix is not offered there.
|
|
45
|
+
- **Boundaries are narrowed only by sound, closed rules.** `haskell-cpp.js` decides a CPP conditional only from the file, a project-stated
|
|
46
|
+
compiler (`with-compiler` pin; `tested-with` only when every item is an exact version and all agree, recorded as an assumption) or the hull
|
|
47
|
+
of cabal `build-depends` bounds; an unknown macro is undecided, never 0, and an undecided conditional keeps every branch. `haskell-boundaries.js`
|
|
48
|
+
accepts a TH declaration splice only when the generator is IMPORTED from its upstream module (not shadowed/hidden) and every token is a name
|
|
49
|
+
quote, literal, documented setting or restricted option update; a quasi-quote only when it is a raw-string or non-interpolating quoter. The
|
|
50
|
+
accepted kinds (`th-safe-splice`, `quasiquote-inert`) are disclosed, never in the adapter's opaque set, and `preprocessForSyntax` feeds the same
|
|
51
|
+
decisions to the semantic IR. Widening any allowlist needs a both-directions test in `test/haskell/haskell-boundaries.test.js`.
|
|
52
|
+
- **The Nix feed is the same contract over a different source.** `nix-advisory-feed.js` uses the NVD CVE API 2.0 keyed by CPE
|
|
53
|
+
`vendor:product` (OSV has no nixpkgs ecosystem), behind `AGENTIC_SECURITY_NIX_ADVISORIES_LIVE=1`. Coverage is per CPE identity
|
|
54
|
+
(`NixAdvisoryData.cpeCoverage`), and `nix-sca.js` reports an uncovered identity, or a component with no CPE, as `unknown` with
|
|
55
|
+
`feedCoverage: 'incomplete'`, never `not-affected`. A snapshot with no `covered` table (pinned, hand-written) is matched exactly as
|
|
56
|
+
before. The prefetch (`prefetchNixAdvisoryFeed`, `resolved-pass.js`) asks only for identities `closureIdentities` says the matcher
|
|
57
|
+
will use; metadata comes only from the operator (`AGENTIC_SECURITY_NIX_META`). NVD is rate limited (5/30 s keyless): never raise
|
|
58
|
+
the request caps or lower the spacing in `nix-advisory-feed.js` without re-reading the NVD limits.
|
|
29
59
|
- **Pragma** (`pragma.js`): comment-aware, line-scoped, exact rule-id match, logged. A line-less finding cannot be suppressed.
|
|
30
60
|
|
|
31
61
|
## Module map
|
|
@@ -33,13 +63,34 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
|
|
|
33
63
|
| Group | Files |
|
|
34
64
|
|---|---|
|
|
35
65
|
| Shared | `discovery.js` (sources, manifests, explicit exports, exclusions, import graph, invalidation digests), `contracts.js`, `assurance.js` (scan-health inputs, conditions, limitations), `engine-pass.js` (language supply chain wired into the engine), `resolved-pass.js` (plan / Stack export / Nix closure / opt-in evaluation reachable from the CLI), `context.js` (project context for partial scans, fix previews and dependency upgrades, model prompt extras), `state-artifacts.js`, `pragma.js`, `secrets.js`, `bom.js`, `bom-validate.js`, `aibom.js`, `compliance-map.js`, `bridges.js`, `witness.js`, `fix-lifecycle.js` (the one fix lifecycle: path, syntax, rescan, compile, backup, history, undo) |
|
|
36
|
-
| Haskell | `haskell-parser.js`, `haskell-grammar.js`, `haskell-adapter.js`, `haskell-ir.js`, `haskell-models.js` (the single registry of sources, sinks, sanitizers; `dataflow/catalog-haskell.js` is generated from it), `haskell-guards.js`, `haskell-security-rules.js`, `haskell-web.js`, `haskell-findings.js`, `haskell-syntax.js`, `haskell-sweep.js`, `haskell-disclosure.js`, `haskell-llm.js`, `haskell-manifests.js`, `haskell-resolved-graph.js`, `haskell-sca.js`, `haskell-supply.js`, `haskell-fix.js` |
|
|
66
|
+
| Haskell | `haskell-parser.js`, `haskell-grammar.js`, `haskell-adapter.js`, `haskell-ir.js`, `haskell-models.js` (the single registry of sources, sinks, sanitizers; `dataflow/catalog-haskell.js` is generated from it), `haskell-guards.js`, `haskell-security-rules.js`, `haskell-web.js`, `haskell-findings.js`, `haskell-syntax.js`, `haskell-sweep.js`, `haskell-disclosure.js`, `haskell-llm.js`, `haskell-manifests.js`, `haskell-resolved-graph.js`, `haskell-sca.js`, `cvss.js` (CVSS v3.x base-score calculator and advisory-severity selection, shared by the Haskell and Nix matchers; v4 is not scored and says so), `haskell-advisory-feed.js` (the opt-in live Hackage feed: OSV fetch, per-package coverage, operator-only snapshot), `haskell-supply.js`, `haskell-fix.js` |
|
|
67
|
+
| Haskell | `haskell-parser.js`, `haskell-cpp.js` (three-valued CPP conditional evaluation + project context from cabal), `haskell-boundaries.js` (closed set of safe TH declaration splices and inert quasi-quotes), `haskell-grammar.js`, `haskell-adapter.js`, `haskell-ir.js`, `haskell-models.js` (the single registry of sources, sinks, sanitizers; `dataflow/catalog-haskell.js` is generated from it), `haskell-guards.js`, `haskell-security-rules.js`, `haskell-web.js`, `haskell-findings.js`, `haskell-syntax.js`, `haskell-sweep.js`, `haskell-disclosure.js`, `haskell-llm.js`, `haskell-manifests.js`, `haskell-resolved-graph.js`, `haskell-sca.js`, `haskell-advisory-feed.js` (the opt-in live Hackage feed: OSV fetch, per-package coverage, operator-only snapshot), `haskell-supply.js`, `haskell-fix.js` |
|
|
37
68
|
| Nix | `nix-parser.js`, `nix-grammar.js`, `nix-ir.js`, `nix-adapter.js`, `nixos-module-resolver.js`, `nixos-option-catalog.js`, `nixos-hardening.js`, `nix-build-trust.js`, `nix-secrets.js`, `nix-script-taint.js`, `nix-privacy.js`, `nix-agents.js`, `nix-inventory.js`, `nix-closure.js`, `nix-sca.js`, `nix-eval-isolation.js`, `nix-fix.js` |
|
|
69
|
+
| Haskell | `haskell-parser.js`, `haskell-grammar.js`, `haskell-adapter.js`, `haskell-ir.js`, `haskell-models.js` (the single registry of sources, sinks, sanitizers; `dataflow/catalog-haskell.js` is generated from it), `haskell-guards.js`, `haskell-security-rules.js`, `haskell-web.js`, `haskell-findings.js`, `haskell-syntax.js`, `haskell-sweep.js`, `haskell-disclosure.js`, `haskell-llm.js`, `haskell-manifests.js`, `haskell-resolved-graph.js`, `haskell-sca.js`, `haskell-advisory-feed.js` (the opt-in live Hackage feed: OSV fetch, per-package coverage, operator-only snapshot), `haskell-supply.js`, `haskell-fix.js` |
|
|
70
|
+
| Nix | `nix-parser.js`, `nix-grammar.js`, `nix-ir.js`, `nix-adapter.js`, `nixos-module-resolver.js`, `nixos-eval-forms.js`, `nixos-option-catalog.js`, `nixos-hardening.js`, `nix-build-trust.js`, `nix-secrets.js`, `nix-script-taint.js`, `nix-privacy.js`, `nix-agents.js`, `nix-inventory.js`, `nix-closure.js`, `nix-sca.js`, `nix-eval-isolation.js`, `nix-fix.js` |
|
|
71
|
+
| Haskell | `haskell-parser.js`, `haskell-grammar.js`, `haskell-adapter.js`, `haskell-ir.js`, `haskell-models.js` (the single registry of sources, sinks, sanitizers; `dataflow/catalog-haskell.js` is generated from it), `haskell-guards.js`, `haskell-security-rules.js`, `haskell-web.js`, `haskell-findings.js`, `haskell-syntax.js`, `haskell-sweep.js`, `haskell-disclosure.js`, `haskell-llm.js`, `haskell-manifests.js`, `haskell-resolved-graph.js`, `haskell-sca.js`, `haskell-advisory-feed.js` (the opt-in live Hackage feed: OSV fetch, per-package coverage, operator-only snapshot), `haskell-supply.js`, `haskell-fix.js` |
|
|
72
|
+
| Nix | `nix-parser.js`, `nix-grammar.js`, `nix-ir.js`, `nix-adapter.js`, `nixos-module-resolver.js`, `nixos-option-catalog.js`, `nixos-hardening.js`, `nix-build-trust.js`, `nix-secrets.js`, `nix-script-taint.js`, `nix-privacy.js`, `nix-agents.js`, `nix-inventory.js`, `nix-closure.js`, `nix-sca.js`, `nix-advisory-feed.js`, `nix-eval-isolation.js`, `nix-fix.js` |
|
|
38
73
|
| Measurement | `accuracy.js` (family-scoped scoring, per-layer), `support-registry.js` (status rules, promotion refusals, frozen-hash demotion) |
|
|
39
74
|
|
|
40
75
|
Related, outside this directory: `dataflow/catalog-haskell.js`, `lineage/haskell-view.js`, `lineage/nix-view.js`,
|
|
41
76
|
`ir/` Haskell/Nix adapters, `report/` format handling, `egress/redact.js`, `llm-validator/`, `discovery/` (hunt partitions Nix).
|
|
42
77
|
|
|
78
|
+
## NixOS option evaluator (soundness rules)
|
|
79
|
+
|
|
80
|
+
`nixos-module-resolver.js` `evalExpr` is a soundness-first subset; `nixos-eval-forms.js` holds its value classes and the
|
|
81
|
+
**allow-list** of library functions (there is no deny-list to forget an entry in). Rules to keep when extending it:
|
|
82
|
+
|
|
83
|
+
- **Any unknown part makes the whole value unknown.** Never default, never pick a branch you cannot decide.
|
|
84
|
+
- **Scope is a correctness issue.** Lexical bindings (let, lambda) beat every `with`; innermost `with` beats outer; the module's
|
|
85
|
+
own `let`s are IR bindings (`ir.bindings`, `scope: 'let'`) and are used only when unique in the file, not also a function
|
|
86
|
+
parameter, not in a `rec` set, and when their `letSpan` encloses `env.pos` (the absolute span of the text being evaluated).
|
|
87
|
+
`evalSpan` on a definition (set by `nix-ir.js` when a leaf sits under let/with/assert only) makes the resolver evaluate the
|
|
88
|
+
whole wrapper. A priority/condition/merge combinator clears it, because those are the collector's job.
|
|
89
|
+
- **Final values must be plain data** (`sealed`/`isPlain`): a closure, partial application or namespace is `unknown`. A
|
|
90
|
+
priority wrapper inside a value is unknown (priority is not recoverable); at the top of a definition the collector reads it.
|
|
91
|
+
- **Bounded**: `maxEvaluations`, `maxExprDepth`, `maxValueSize` are caps that surface as `truncated`, not as a guess.
|
|
92
|
+
- Assumption stated, not proven: `lib` is nixpkgs' `lib` whenever the module declares a `lib` parameter and the file does not rebind it.
|
|
93
|
+
|
|
43
94
|
## Tests and benches
|
|
44
95
|
|
|
45
96
|
| Scope | Command | Notes |
|
|
@@ -50,6 +101,7 @@ Related, outside this directory: `dataflow/catalog-haskell.js`, `lineage/haskell
|
|
|
50
101
|
| Stress | `npm run test:language-stress` | offline scale and a peak-memory ceiling; excluded from `npm test` (it must own the machine) |
|
|
51
102
|
| Needs a compiler | `npm run test:language-tools` | HS-006.AC01 and the support gate; excluded from `npm test`; **fails** without `ghc` |
|
|
52
103
|
| Support registry | `npm run bench:language-support:check` | recomputes the registry from the STORED measurement; fails on any difference |
|
|
104
|
+
| Live feed | `npm run bench:live-feed -- <fetch\|plan\|scan\|merge\|sample\|report>` | `bench/live-feed/`: the opt-in Hackage feed against real projects and real OSV. Needs the network and is NOT in `npm test` or any gate. `RESULTS.md` is a dated measurement; labels in it are model-assessed |
|
|
53
105
|
|
|
54
106
|
The support registry is measured **once per promotion** on a frozen holdout (`bench/language-support/promote.mjs`); never tune
|
|
55
107
|
against the holdout, and any further holdout run counts as repeated use. A change to the engine, the corpus or a label changes the
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { runLanguageAnalysis, reconcileLanguageLedger, languageHealth } from './contracts.js';
|
|
21
21
|
import { createHaskellAdapter } from './haskell-adapter.js';
|
|
22
|
+
import { deriveCppContext } from './haskell-cpp.js';
|
|
22
23
|
import { createNixAdapter } from './nix-adapter.js';
|
|
23
24
|
import { analyzeHaskellManifests } from './haskell-manifests.js';
|
|
24
25
|
import { packageOfModule } from './haskell-models.js';
|
|
@@ -71,14 +72,21 @@ export async function assessLanguageAssurance(input = {}) {
|
|
|
71
72
|
// 1. parse adapters over every language source
|
|
72
73
|
// Boundaries that do not make a file unresolved but that a reader must be told about: CPP (every branch was parsed, the
|
|
73
74
|
// chosen one is not known) and FFI (the foreign code is not analysed).
|
|
75
|
+
const DISCLOSED_KINDS = new Set(['cpp', 'ffi', 'th-safe-splice', 'quasiquote-inert']);
|
|
74
76
|
const disclosed = new Map(); // boundary kind -> {count, files:Set}
|
|
75
77
|
const projectModules = new Set(); const importedModules = new Map(); // module -> files
|
|
76
78
|
const onParse = (file, parse) => {
|
|
77
79
|
if (parse && parse.module && parse.module.name) projectModules.add(parse.module.name);
|
|
78
80
|
for (const i of (parse && parse.imports) || []) if (i && i.module) { const e = importedModules.get(i.module) || new Set(); e.add(file); importedModules.set(i.module, e); }
|
|
79
|
-
for (const b of (parse && parse.boundaries) || [])
|
|
81
|
+
for (const b of (parse && parse.boundaries) || []) {
|
|
82
|
+
if (!DISCLOSED_KINDS.has(b.kind)) continue;
|
|
83
|
+
const e = disclosed.get(b.kind) || { count: 0, files: new Set(), decided: 0, undecided: 0, assumptions: new Set() };
|
|
84
|
+
e.count += b.count || 1; e.files.add(file);
|
|
85
|
+
if (b.kind === 'cpp') { e.decided += b.decided || 0; e.undecided += b.undecided || 0; for (const a of b.assumptions || []) e.assumptions.add(a); }
|
|
86
|
+
disclosed.set(b.kind, e);
|
|
87
|
+
}
|
|
80
88
|
};
|
|
81
|
-
const adapters = input.adapters || [createHaskellAdapter({ onParse }), createNixAdapter()];
|
|
89
|
+
const adapters = input.adapters || [createHaskellAdapter({ onParse, cpp: deriveCppContext(files) }), createNixAdapter()];
|
|
82
90
|
const run = await runLanguageAnalysis({ files, adapters, timeoutMs: input.timeoutMs || 0 });
|
|
83
91
|
const reconcile = reconcileLanguageLedger(run.ledger, files);
|
|
84
92
|
const base = languageHealth({ ledger: run.ledger, outcomes: run.outcomes });
|
|
@@ -97,8 +105,17 @@ export async function assessLanguageAssurance(input = {}) {
|
|
|
97
105
|
for (const o of opaque) { const k = String(o.detail).replace(/^opaque-boundary:\s*/, ''); kinds[k] = (kinds[k] || 0) + 1; }
|
|
98
106
|
for (const [k, n] of Object.entries(kinds).sort()) limitations.push({ kind: 'opaque-boundary', boundary: k, count: n, note: 'a documented limit of static analysis: this code is not analysed, and is not reported clean' });
|
|
99
107
|
}
|
|
100
|
-
const DISCLOSED_NOTE = {
|
|
101
|
-
|
|
108
|
+
const DISCLOSED_NOTE = {
|
|
109
|
+
cpp: 'preprocessor conditionals: a conditional the file or the project decides keeps only its live branch, every other one keeps all of its branches, and which branch is compiled is then not known',
|
|
110
|
+
ffi: 'foreign declarations: the foreign code is not analysed and its effects are not modelled',
|
|
111
|
+
'th-safe-splice': 'declaration-level Template Haskell splices of a known generator applied to names and literals only: assumed to be the upstream generator, nothing is run and the generated declarations are not modelled',
|
|
112
|
+
'quasiquote-inert': 'quasi-quotes of a known raw-string or non-interpolating quoter: read as a string literal on the assumption that the quoter is the upstream one',
|
|
113
|
+
};
|
|
114
|
+
for (const [k, e] of [...disclosed.entries()].sort()) {
|
|
115
|
+
const extra = k === 'cpp' ? { decidedConditionals: e.decided, undecidedConditionals: e.undecided, ...(e.assumptions.size ? { assumptions: [...e.assumptions].sort() } : {}) } : {};
|
|
116
|
+
const assumed = k === 'cpp' && e.assumptions.size ? `; some conditionals were decided on a stated project assumption (${[...e.assumptions].sort().join(', ')}), so a branch dead under it was not analysed` : '';
|
|
117
|
+
limitations.push({ kind: 'opaque-boundary', boundary: k, count: e.count, files: [...e.files].sort().slice(0, 20), ...extra, note: `a documented limit of static analysis (${DISCLOSED_NOTE[k]}${assumed})` });
|
|
118
|
+
}
|
|
102
119
|
// Imported modules this scan has no model for: their functions are ordinary, opaque calls (never a source, sink or
|
|
103
120
|
// sanitizer), so a taint flow through them is widened and disclosed on the finding. Listed so a reader knows how much of the
|
|
104
121
|
// import surface the models cover. A limitation, not a condition: most real projects import unmodelled modules.
|
package/src/language/context.js
CHANGED
|
@@ -98,6 +98,8 @@ export async function languageFixPreview(finding, files, { apply = false, root =
|
|
|
98
98
|
status: res.status, applied: res.applied === true, backup: res.backup || null,
|
|
99
99
|
label: plan.label || null, tier: res.tier || plan.tier || null,
|
|
100
100
|
file: plan.file, before: plan.before, after: plan.after,
|
|
101
|
+
// A fix that spans several files lists every one of them; `file`/`before`/`after` stay the first for single-file callers.
|
|
102
|
+
edits: Array.isArray(plan.edits) && plan.edits.length > 1 ? plan.edits.map((e) => ({ file: e.file, before: e.before, after: e.after })) : null,
|
|
101
103
|
diff: res.preview || hsDiff(plan.file, plan.before, plan.after),
|
|
102
104
|
gates: res.gates || null, reason: (res.status === 'verified' || res.status === 'applied') ? null : (res.reason || null),
|
|
103
105
|
explanation: plan.explanation || null, consequences: res.consequences || plan.consequences || [],
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// CVSS v3.x base-score calculator and advisory-severity selection for the Haskell and Nix SCA matchers.
|
|
2
|
+
// Pure: no I/O. Never invents a rating: a vector that cannot be parsed, or a CVSS version this module does not compute
|
|
3
|
+
// (v4 needs lookup tables that are not shipped), yields no score and says why.
|
|
4
|
+
|
|
5
|
+
const W = {
|
|
6
|
+
AV: { N: 0.85, A: 0.62, L: 0.55, P: 0.2 },
|
|
7
|
+
AC: { L: 0.77, H: 0.44 },
|
|
8
|
+
UI: { N: 0.85, R: 0.62 },
|
|
9
|
+
CIA: { H: 0.56, L: 0.22, N: 0 },
|
|
10
|
+
};
|
|
11
|
+
const PR = { U: { N: 0.85, L: 0.62, H: 0.27 }, C: { N: 0.85, L: 0.68, H: 0.5 } };
|
|
12
|
+
const REQUIRED = ['AV', 'AC', 'PR', 'UI', 'S', 'C', 'I', 'A'];
|
|
13
|
+
|
|
14
|
+
// CVSS 3.1 Roundup (spec appendix A, integer arithmetic so float error cannot move a score across a boundary).
|
|
15
|
+
function roundUp31(x) {
|
|
16
|
+
const i = Math.round(x * 100000);
|
|
17
|
+
return i % 10000 === 0 ? i / 100000 : (Math.floor(i / 10000) + 1) / 10;
|
|
18
|
+
}
|
|
19
|
+
const roundUp30 = (x) => Math.ceil(x * 10) / 10;
|
|
20
|
+
|
|
21
|
+
/** @returns {{ok:true, version:string, score:number, vector:string}|{ok:false, reason:string}} */
|
|
22
|
+
export function cvssV3BaseScore(vector) {
|
|
23
|
+
if (typeof vector !== 'string') return { ok: false, reason: 'the CVSS vector is not a string' };
|
|
24
|
+
const parts = vector.trim().split('/');
|
|
25
|
+
const m = /^CVSS:(3\.[01])$/.exec(parts[0] || '');
|
|
26
|
+
if (!m) return { ok: false, reason: /^CVSS:4/.test(parts[0] || '') ? 'CVSS v4 vectors are not scored by this tool' : 'the vector is not a CVSS v3.0/v3.1 vector' };
|
|
27
|
+
const version = m[1];
|
|
28
|
+
const metrics = {};
|
|
29
|
+
for (const p of parts.slice(1)) {
|
|
30
|
+
const kv = /^([A-Za-z]+):([A-Za-z])$/.exec(p);
|
|
31
|
+
if (!kv || kv[1] in metrics) return { ok: false, reason: `malformed or repeated metric "${p}"` };
|
|
32
|
+
metrics[kv[1]] = kv[2];
|
|
33
|
+
}
|
|
34
|
+
for (const k of REQUIRED) if (!(k in metrics)) return { ok: false, reason: `missing base metric ${k}` };
|
|
35
|
+
if (metrics.S !== 'C' && metrics.S !== 'U') return { ok: false, reason: `invalid scope value "${metrics.S}"` };
|
|
36
|
+
const changed = metrics.S === 'C';
|
|
37
|
+
const av = W.AV[metrics.AV], ac = W.AC[metrics.AC], ui = W.UI[metrics.UI];
|
|
38
|
+
const c = W.CIA[metrics.C], i = W.CIA[metrics.I], a = W.CIA[metrics.A];
|
|
39
|
+
const pr = PR[metrics.S][metrics.PR];
|
|
40
|
+
if ([av, ac, ui, c, i, a, pr].some((x) => x === undefined)) return { ok: false, reason: 'an unknown value in a base metric' };
|
|
41
|
+
const iss = 1 - (1 - c) * (1 - i) * (1 - a);
|
|
42
|
+
const impact = changed ? 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15) : 6.42 * iss;
|
|
43
|
+
const expl = 8.22 * av * ac * pr * ui;
|
|
44
|
+
const up = version === '3.1' ? roundUp31 : roundUp30;
|
|
45
|
+
const score = impact <= 0 ? 0 : changed ? up(Math.min(1.08 * (impact + expl), 10)) : up(Math.min(impact + expl, 10));
|
|
46
|
+
return { ok: true, version, score, vector: vector.trim() };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function levelFromScore(score) {
|
|
50
|
+
if (!(score > 0)) return null;
|
|
51
|
+
return score >= 9 ? 'critical' : score >= 7 ? 'high' : score >= 4 ? 'medium' : 'low';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const NAMED = { critical: 'critical', high: 'high', moderate: 'medium', medium: 'medium', low: 'low' };
|
|
55
|
+
export const normalizeSeverityName = (s) => (typeof s === 'string' ? NAMED[s.trim().toLowerCase()] || null : null);
|
|
56
|
+
|
|
57
|
+
export const NO_RATING_BASIS = 'the advisory carries no severity rating';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Pick the advisory's own severity from an OSV record: CVSS vectors at record level and on each affected entry, then
|
|
61
|
+
* database_specific.severity (record, then affected). The highest parseable CVSS score wins; a named severity is used
|
|
62
|
+
* only when no vector could be scored. Returns {level:null, basis} when nothing usable exists.
|
|
63
|
+
*/
|
|
64
|
+
export function advisorySeverity(rec) {
|
|
65
|
+
const vectors = [];
|
|
66
|
+
const named = [];
|
|
67
|
+
const take = (sevArr, dbs) => {
|
|
68
|
+
for (const s of Array.isArray(sevArr) ? sevArr : []) if (s && typeof s.score === 'string') vectors.push(s.score);
|
|
69
|
+
if (dbs && typeof dbs.severity === 'string') named.push(dbs.severity);
|
|
70
|
+
};
|
|
71
|
+
if (rec && typeof rec === 'object') {
|
|
72
|
+
take(rec.severity, rec.database_specific);
|
|
73
|
+
for (const a of Array.isArray(rec.affected) ? rec.affected : []) if (a && typeof a === 'object') take(a.severity, a.database_specific);
|
|
74
|
+
}
|
|
75
|
+
let best = null; const why = [];
|
|
76
|
+
for (const v of vectors) {
|
|
77
|
+
const r = cvssV3BaseScore(v);
|
|
78
|
+
if (r.ok) { if (!best || r.score > best.score) best = r; } else why.push(r.reason);
|
|
79
|
+
}
|
|
80
|
+
if (best) {
|
|
81
|
+
const level = levelFromScore(best.score);
|
|
82
|
+
if (level) return { level, score: best.score, vector: best.vector, basis: `CVSS v${best.version} base score ${best.score.toFixed(1)} from the advisory` };
|
|
83
|
+
why.push('the CVSS base score is 0.0 (no impact)');
|
|
84
|
+
}
|
|
85
|
+
for (const n of named) {
|
|
86
|
+
const level = normalizeSeverityName(n);
|
|
87
|
+
if (level) return { level, score: null, vector: null, basis: `severity "${n}" stated by the advisory (database_specific)` };
|
|
88
|
+
why.push(`unrecognised severity "${String(n).slice(0, 40)}"`);
|
|
89
|
+
}
|
|
90
|
+
return { level: null, score: null, vector: null, basis: why.length ? `${NO_RATING_BASIS} that could be used (${[...new Set(why)].join('; ')})` : NO_RATING_BASIS };
|
|
91
|
+
}
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
// Preview never writes. Apply writes a backup first and refuses a file that changed since planning.
|
|
11
11
|
// Undo restores the backup byte for byte.
|
|
12
12
|
|
|
13
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
13
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
|
+
import { randomBytes } from 'node:crypto';
|
|
15
16
|
import { statePath } from '../posture/state-dir.js';
|
|
16
17
|
import { maybeEncryptForWrite, maybeDecryptForRead } from '../posture/encryption-provider.js';
|
|
17
18
|
import { recordExternalFix, markRevertedByBackup } from '../posture/fix-history.js';
|
|
@@ -30,31 +31,102 @@ export function unifiedDiff(file, a, b) {
|
|
|
30
31
|
}
|
|
31
32
|
const safeRel = (root, rel) => { const p = join(root, rel); if (!p.startsWith(join(root) + '/')) throw new Error(`path escapes the root: ${rel}`); return p; };
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
/** Save the pre-image of `rel` (customer source: it follows the encryption policy) and return its backup. Writes nothing to the target. */
|
|
35
|
+
function saveBackup(root, rel, before, after) {
|
|
34
36
|
const target = safeRel(root, rel);
|
|
35
|
-
const id = `${Date.now().toString(36)}-${
|
|
37
|
+
const id = `${Date.now().toString(36)}-${randomBytes(5).toString('hex')}`;
|
|
36
38
|
const dir = statePath(root, 'fix-backups', id);
|
|
37
39
|
const disk = existsSync(target) ? readFileSync(target, 'utf8') : null;
|
|
38
40
|
if (disk !== before) throw new Error('the file changed on disk since the fix was planned; refusing to overwrite');
|
|
39
|
-
//
|
|
40
|
-
// that REQUIRES encryption with no working provider refuses the fix before anything is written.
|
|
41
|
+
// A policy that REQUIRES encryption with no working provider refuses the fix before anything is written.
|
|
41
42
|
const enc = maybeEncryptForWrite(root, 'fix-backups', before);
|
|
42
43
|
if (!enc.ok) throw new Error(enc.reason);
|
|
43
44
|
mkdirSync(dir, { recursive: true });
|
|
44
45
|
writeFileSync(join(dir, 'original'), enc.content);
|
|
45
46
|
writeFileSync(join(dir, 'meta.json'), JSON.stringify({ file: rel, appliedAt: new Date().toISOString(), sizeBefore: before.length, sizeAfter: after.length }));
|
|
47
|
+
return { id, dir, target };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function writeWithBackup(root, rel, before, after) {
|
|
51
|
+
const { id, dir, target } = saveBackup(root, rel, before, after);
|
|
46
52
|
writeFileSync(target, after);
|
|
47
53
|
return { id, dir };
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Apply several file edits as ONE unit. Nothing is written until every file has been checked (inside the root, unchanged
|
|
58
|
+
* since planning) and every pre-image is backed up. If a write then fails, every file already written (and the one that
|
|
59
|
+
* failed, which may be truncated) is put back, and the error says whether that rollback was complete.
|
|
60
|
+
* @param {string} root
|
|
61
|
+
* @param {Array<{file:string,before:string,after:string}>} edits
|
|
62
|
+
* @param {{writeFile?:Function}} [hooks] test seam for the file writer
|
|
63
|
+
* @returns {{id:string, dir:string, members:Array<{id:string,dir:string,file:string}>}}
|
|
64
|
+
*/
|
|
65
|
+
export function writeManyWithBackup(root, edits, hooks = {}) {
|
|
66
|
+
const write = hooks.writeFile || writeFileSync;
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
for (const e of edits) { if (seen.has(e.file)) throw new Error(`the same file is edited twice in one fix: ${e.file}`); seen.add(e.file); safeRel(root, e.file); }
|
|
69
|
+
const members = [];
|
|
70
|
+
const cleanup = () => { for (const m of members) { try { rmSync(m.dir, { recursive: true, force: true }); } catch { /* best effort */ } } };
|
|
71
|
+
try { for (const e of edits) { const b = saveBackup(root, e.file, e.before, e.after); members.push({ id: b.id, dir: b.dir, file: e.file, target: b.target, before: e.before, after: e.after }); } }
|
|
72
|
+
catch (err) { cleanup(); throw new Error(`${err.message} (no file was written)`); }
|
|
73
|
+
const written = [];
|
|
74
|
+
for (const m of members) {
|
|
75
|
+
written.push(m);
|
|
76
|
+
try { write(m.target, m.after); } catch (err) {
|
|
77
|
+
const failed = [];
|
|
78
|
+
for (const w of written.slice().reverse()) { try { write(w.target, w.before); } catch { failed.push(w.file); } }
|
|
79
|
+
if (!failed.length) cleanup();
|
|
80
|
+
const e = new Error(failed.length
|
|
81
|
+
? `write to ${m.file} failed (${err.message}); ROLLBACK INCOMPLETE, restore these from the backups under .agentic-security/fix-backups: ${failed.join(', ')}`
|
|
82
|
+
: `write to ${m.file} failed (${err.message}); every file in this fix was restored to its original content`);
|
|
83
|
+
e.rolledBack = failed.length === 0; e.rollbackFailed = failed; e.failedFile = m.file;
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const groupId = `g${Date.now().toString(36)}-${randomBytes(5).toString('hex')}`;
|
|
88
|
+
const dir = statePath(root, 'fix-backups', groupId);
|
|
89
|
+
mkdirSync(dir, { recursive: true });
|
|
90
|
+
writeFileSync(join(dir, 'meta.json'), JSON.stringify({ file: members[0].file, group: members.map((m) => ({ id: m.id, file: m.file })), appliedAt: new Date().toISOString() }));
|
|
91
|
+
return { id: groupId, dir, members: members.map((m) => ({ id: m.id, dir: m.dir, file: m.file })) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Undo a fix. A multi-file fix is restored as a unit: every pre-image is read and every target resolved before the first
|
|
96
|
+
* write, and if a restore fails the files already restored are put back to the content they had, so a partial undo is
|
|
97
|
+
* never left behind.
|
|
98
|
+
*/
|
|
99
|
+
export function undoFix(root, backupId, hooks = {}) {
|
|
100
|
+
const write = hooks.writeFile || writeFileSync;
|
|
52
101
|
const dir = statePath(root, 'fix-backups', backupId);
|
|
53
102
|
const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8'));
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
103
|
+
if (!Array.isArray(meta.group)) {
|
|
104
|
+
const target = safeRel(root, meta.file);
|
|
105
|
+
writeFileSync(target, maybeDecryptForRead(readFileSync(join(dir, 'original'), 'utf8')));
|
|
106
|
+
try { markRevertedByBackup(root, backupId); } catch { /* history is best effort */ }
|
|
107
|
+
return { ok: true, file: meta.file, files: [meta.file] };
|
|
108
|
+
}
|
|
109
|
+
const items = meta.group.map((m) => {
|
|
110
|
+
const mdir = statePath(root, 'fix-backups', m.id);
|
|
111
|
+
const md = JSON.parse(readFileSync(join(mdir, 'meta.json'), 'utf8'));
|
|
112
|
+
return { file: md.file, target: safeRel(root, md.file), original: maybeDecryptForRead(readFileSync(join(mdir, 'original'), 'utf8')), id: m.id };
|
|
113
|
+
});
|
|
114
|
+
const current = items.map((it) => (existsSync(it.target) ? readFileSync(it.target, 'utf8') : null));
|
|
115
|
+
const touched = [];
|
|
116
|
+
for (let i = 0; i < items.length; i++) {
|
|
117
|
+
touched.push(i);
|
|
118
|
+
try { write(items[i].target, items[i].original); } catch (err) {
|
|
119
|
+
const failed = [];
|
|
120
|
+
for (const j of touched.slice().reverse()) { try { if (current[j] !== null) write(items[j].target, current[j]); } catch { failed.push(items[j].file); } }
|
|
121
|
+
const e = new Error(failed.length
|
|
122
|
+
? `undo failed at ${items[i].file} (${err.message}); ROLLBACK INCOMPLETE for: ${failed.join(', ')}`
|
|
123
|
+
: `undo failed at ${items[i].file} (${err.message}); no file was changed`);
|
|
124
|
+
e.rolledBack = failed.length === 0; e.rollbackFailed = failed;
|
|
125
|
+
throw e;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const id of [backupId, ...items.map((it) => it.id)]) { try { markRevertedByBackup(root, id); } catch { /* history is best effort */ } }
|
|
129
|
+
return { ok: true, file: items[0].file, files: items.map((it) => it.file) };
|
|
58
130
|
}
|
|
59
131
|
|
|
60
132
|
/**
|
|
@@ -75,10 +147,14 @@ export async function runFixLifecycle(o) {
|
|
|
75
147
|
const { plan, files, finding } = o;
|
|
76
148
|
const gates = {};
|
|
77
149
|
// A patch may only touch a file inside the project: a target that escapes the root is refused before anything else runs.
|
|
78
|
-
|
|
79
|
-
|
|
150
|
+
// A plan may carry `edits` (several files, one logical fix); a plan without them is the single edit of `plan.file`.
|
|
151
|
+
const edits = Array.isArray(plan.edits) && plan.edits.length ? plan.edits : [{ file: plan.file, before: plan.before, after: plan.after }];
|
|
152
|
+
const escaping = edits.filter((e) => !isRootRelativePath(e.file));
|
|
153
|
+
gates.path = { ok: escaping.length === 0, detail: edits.map((e) => e.file).join(', ') };
|
|
154
|
+
if (!gates.path.ok) return { status: 'blocked', applied: false, reason: `path-escape: ${escaping.map((e) => String(e.file).slice(0, 120)).join(', ')} is outside the project root`, plan, gates, label: plan.label };
|
|
80
155
|
gates.syntax = o.syntax(plan);
|
|
81
|
-
const patched = { ...files
|
|
156
|
+
const patched = { ...files };
|
|
157
|
+
for (const e of edits) patched[e.file] = e.after;
|
|
82
158
|
const blocked = (why) => ({ status: 'blocked', applied: false, reason: why, plan, gates, label: plan.label });
|
|
83
159
|
if (!gates.syntax.ok) return blocked(`syntax: ${gates.syntax.detail}`);
|
|
84
160
|
if (o.behaviorCheck) { gates.behavior = o.behaviorCheck(plan); if (gates.behavior && gates.behavior.ok === false) return blocked(`behavior: ${gates.behavior.detail || 'unrelated behavior change'}`); }
|
|
@@ -94,6 +170,12 @@ export async function runFixLifecycle(o) {
|
|
|
94
170
|
gates.rescan = { ok: gone && added.length === 0, originalGone: gone, newMediumOrHigher: [...new Set(added.map((f) => `${f.cwe || f.vuln}@${f.file}:${f.line}`))] };
|
|
95
171
|
if (!gone) return blocked('the original finding is still reported after the patch');
|
|
96
172
|
if (added.length) return blocked(`the patch introduces ${added.length} new medium-or-higher finding(s)`);
|
|
173
|
+
// Optional effective-configuration gate: the rescan above only proves a finding stopped being REPORTED, which a patch that
|
|
174
|
+
// turns the option into a module-system conflict also achieves. The check says whether the option now HAS the intended value.
|
|
175
|
+
if (o.effectiveCheck) {
|
|
176
|
+
gates.effective = o.effectiveCheck(plan, patched, files) || { ok: false, detail: 'no effective-configuration result' };
|
|
177
|
+
if (gates.effective.ok === false) return blocked(`effective: ${gates.effective.detail}`);
|
|
178
|
+
}
|
|
97
179
|
// Optional controlled-witness gate (X-009): the hook returns judgeFixWitness(before, after). A patch that STILL
|
|
98
180
|
// reproduces the effect is blocked; a witness that could not be judged (unsupported, invalid, unavailable) verifies
|
|
99
181
|
// nothing and, unless `requireWitness` is set, does not block (it is recorded as not-verified).
|
|
@@ -106,15 +188,26 @@ export async function runFixLifecycle(o) {
|
|
|
106
188
|
if (gates.compile.ran && !gates.compile.ok) return blocked(`compile: ${gates.compile.detail}`);
|
|
107
189
|
if (o.requireCompile && !gates.compile.ran) return blocked(`compile verification required but ${gates.compile.detail}`);
|
|
108
190
|
const verified = { syntax: true, rescan: true, compile: gates.compile.ran ? 'passed' : 'not-run', witness: gates.witness ? (gates.witness.verified ? 'passed' : 'not-verified') : 'not-run' };
|
|
109
|
-
const res = { status: 'verified', applied: false, plan, gates, label: plan.label, verified, preview: unifiedDiff(
|
|
191
|
+
const res = { status: 'verified', applied: false, plan, gates, label: plan.label, verified, preview: edits.map((e) => unifiedDiff(e.file, e.before, e.after)).join('\n'), files: edits.map((e) => e.file) };
|
|
110
192
|
if (o.apply) {
|
|
111
193
|
if (!o.root) return blocked('apply requires a root directory');
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
194
|
+
const base = { scanRoot: o.root, findingId: finding.id || `${finding.file}:${finding.line}`, ruleId: finding.rule || null, vuln: finding.vuln || null, stableId: finding.stableId || null, fixLabel: plan.label || null, verification: verified, findingProvenance: finding.findingProvenance || null };
|
|
195
|
+
if (edits.length === 1) {
|
|
196
|
+
res.backup = writeWithBackup(o.root, plan.file, plan.before, plan.after);
|
|
197
|
+
res.applied = true; res.status = 'applied';
|
|
198
|
+
// One ledger: the same fix-history `agentic-security undo` reads, with the tier and the gates that passed.
|
|
199
|
+
try { res.history = await recordExternalFix({ ...base, file: plan.file, originalContent: plan.before, newContent: plan.after, backup: res.backup }); } catch { res.history = null; }
|
|
200
|
+
} else {
|
|
201
|
+
try { res.backup = writeManyWithBackup(o.root, edits, { writeFile: o.writeFile }); } catch (err) {
|
|
202
|
+
return { ...blocked(`apply failed, nothing was left changed: ${err.message}`), rolledBack: err.rolledBack !== false, rollbackFailed: err.rollbackFailed || [] };
|
|
203
|
+
}
|
|
204
|
+
res.applied = true; res.status = 'applied';
|
|
205
|
+
// One ledger entry per touched file, all carrying the group id so `undo` reverts them together.
|
|
206
|
+
res.history = [];
|
|
207
|
+
for (let i = 0; i < edits.length; i++) {
|
|
208
|
+
try { res.history.push(await recordExternalFix({ ...base, file: edits[i].file, originalContent: edits[i].before, newContent: edits[i].after, backup: res.backup.members[i], languageGroupId: res.backup.id })); } catch { res.history.push(null); }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
118
211
|
}
|
|
119
212
|
return res;
|
|
120
213
|
}
|