@clear-capabilities/agentic-security-scanner 0.154.0 → 0.155.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 +13 -0
- package/bin/agentic-security.js +8 -0
- package/dist/1919.index.js +445 -0
- package/dist/2862.index.js +2 -2
- package/dist/8551.index.js +47 -19
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +6 -4
- package/src/engine.js +2 -2
- package/src/language/assurance.js +2 -2
- package/src/language/haskell-supply.js +6 -6
- package/src/language/resolved-pass.js +45 -17
- package/src/language/support-registry.js +66 -1
- package/src/language/trusted-inputs.js +38 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
5b1c3482deb6bf15eb606402755278ad1e04a5b2e8b12c5b5246c5dcd4028b50 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.155.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",
|
|
@@ -81,12 +81,12 @@
|
|
|
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
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
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-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",
|
|
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-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
88
|
"bench:language-support:perf": "node ../bench/language-support/perf.mjs",
|
|
89
|
-
"test:lifecycle": "node --test 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",
|
|
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
90
|
"test:eval": "node --test test/independent-eval.test.js test/realworld-recall.test.js",
|
|
91
91
|
"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
92
|
"eval:independent": "node ../bench/independent-eval/runner.mjs",
|
|
@@ -199,10 +199,12 @@
|
|
|
199
199
|
"test:ci-parity": "CI=true GITHUB_ACTIONS=true node --test test/backward-default.test.js test/ci-parity.test.js test/container-taint.test.js test/deep-taint.test.js test/engine-recall.test.js test/engine-reconnect.test.js test/gate-verdict-cache.test.js test/ignore-pragma.test.js test/java-taint-flow.test.js test/kt-taint-flow.test.js test/py-annotation-sources.test.js test/runscan-deep-option.test.js test/sanitizer-typed-flow.test.js",
|
|
200
200
|
"docs:render-language": "node ../scripts/render-language-docs.mjs",
|
|
201
201
|
"docs:capture-language": "node ../scripts/capture-language-examples.mjs",
|
|
202
|
+
"completion-status": "node ../scripts/write-completion-status.mjs",
|
|
202
203
|
"docs:check-language": "node ../scripts/render-language-docs.mjs --check && node ../scripts/capture-language-examples.mjs --check",
|
|
203
204
|
"test:nixos-host": "node --test test/nix/nixos-host-runtime.test.js",
|
|
204
205
|
"test:language-gates": "node --test --test-concurrency=1 test/language/language-benchmark-gates.test.js",
|
|
205
|
-
"test:language-slow": "node --test --test-concurrency=1 test/language/language-doc-examples-metrics.test.js test/language/language-end-to-end.test.js test/language/language-package-ci.test.js test/language/language-scan-modes.test.js"
|
|
206
|
+
"test:language-slow": "node --test --test-concurrency=1 test/language/language-doc-examples-metrics.test.js test/language/language-end-to-end.test.js test/language/language-package-ci.test.js test/language/language-scan-modes.test.js",
|
|
207
|
+
"test:chrome": "AGENTIC_SECURITY_CHROME_TESTS=1 node --test test/export-image.test.js test/server/cmd-dataflow-export.test.js"
|
|
206
208
|
},
|
|
207
209
|
"author": "Ross Young <ross@clearcapabilities.com>",
|
|
208
210
|
"license": "PolyForm-Internal-Use-1.0.0",
|
package/src/engine.js
CHANGED
|
@@ -9228,7 +9228,7 @@ function _deterministicFileTimings(timings) {
|
|
|
9228
9228
|
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
9229
|
// X-010: Hackage and Nix components, dependency edges and target provenance. Kept beside (not inside) the ordinary
|
|
9230
9230
|
// component list so the existing consumers are untouched; the BOM emitters and the SBOM diff read it explicitly.
|
|
9231
|
-
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?{closure:_nc.closure}:{})});}}catch(_){_languageBom=null;}
|
|
9231
|
+
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
9232
|
aF.push(...(runDetector(_detectorErrors,'<project>','scanDbTaintCrossFile',()=>scanDbTaintCrossFile(fc))||[]));
|
|
9233
9233
|
aF.push(...(runDetector(_detectorErrors,'<project>','scanCsharpCrossFile',()=>scanCsharpCrossFile(fc))||[]));
|
|
9234
9234
|
// SARD_80_F1 W5.41/W5.42 — unlike the ADD-only cross-file passes above,
|
|
@@ -11038,7 +11038,7 @@ function _deterministicFileTimings(timings) {
|
|
|
11038
11038
|
_languageCoverage = (await assessLanguageAssurance({
|
|
11039
11039
|
files: allFileContents, findings: finalFindings, timeoutMs: Number(env.AGENTIC_SECURITY_LANG_TIMEOUT_MS) || 0,
|
|
11040
11040
|
disabled: [...String(env.AGENTIC_SECURITY_LANG_DISABLE || '').split(',').map(x => x.trim()).filter(Boolean), ...((_hasHaskellSource && !_deepEnabled) ? ['haskell:taint'] : [])],
|
|
11041
|
-
|
|
11041
|
+
...(() => { const a = configuredAdvisoryDb(scanRoot); return { advisoryDb: a.db, advisoryReason: a.db ? null : a.reason }; })(),
|
|
11042
11042
|
policyExcluded,
|
|
11043
11043
|
sizeSkipped,
|
|
11044
11044
|
supplyGaps: _languageSupplyGaps,
|
|
@@ -134,14 +134,14 @@ export async function assessLanguageAssurance(input = {}) {
|
|
|
134
134
|
const scaRequired = (required.haskell || []).includes('sca') && !disabled.has('haskell:sca');
|
|
135
135
|
if (present.has('haskell') && scaRequired && manifests.length) {
|
|
136
136
|
const db = input.advisoryDb;
|
|
137
|
-
if (!db) conditions.push(
|
|
137
|
+
if (!db) conditions.push(`no Hackage advisory snapshot is loaded: Haskell dependency vulnerabilities were not assessed${input.advisoryReason ? `. ${input.advisoryReason}` : ''}`);
|
|
138
138
|
else if (db.stale) conditions.push(`the Hackage advisory snapshot is stale (${db.ageDays == null ? 'age unknown' : `${Math.floor(db.ageDays)} day(s) old`}, limit ${db.maxAgeDays})`);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
// 3b. resolved dependency data and the Nix closure: a stale, malformed or unchecked input is stated, never silently dropped
|
|
142
142
|
for (const g of input.supplyGaps || []) {
|
|
143
143
|
if (!g || !g.kind) continue;
|
|
144
|
-
if (/unavailable|stale|malformed|failed|unverified/.test(g.kind)) conditions.push(`${g.kind.replace(/^(?:resolved|closure)-/, '')}: ${String(g.detail || '').slice(0, 220)}`);
|
|
144
|
+
if (/unavailable|stale|malformed|failed|unverified|refused|contradicts/.test(g.kind)) conditions.push(`${g.kind.replace(/^(?:resolved|closure)-/, '')}: ${String(g.detail || '').slice(0, 220)}`);
|
|
145
145
|
else limitations.push({ kind: 'supply-gap', gap: g.kind, note: String(g.detail || '').slice(0, 220) });
|
|
146
146
|
}
|
|
147
147
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Haskell supply-chain orchestration for a project (HS-009): manifests -> components -> advisories + policy.
|
|
2
2
|
// The pure logic lives in haskell-sca.js; this module only gathers inputs and shapes supply-chain entries.
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import { readFileSync
|
|
4
|
+
import { resolveOperatorSnapshot, IGNORED_NOTE } from './trusted-inputs.js';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { analyzeHaskellManifests } from './haskell-manifests.js';
|
|
8
8
|
import { buildHaskellIR } from './haskell-ir.js';
|
|
@@ -62,12 +62,12 @@ function mergeResolved(declared, resolved) {
|
|
|
62
62
|
|
|
63
63
|
/** Advisory data configured for this scan: a hash-pinned snapshot from the environment or the project's state dir. */
|
|
64
64
|
export function configuredAdvisoryDb(root, env = process.env) {
|
|
65
|
-
const
|
|
66
|
-
if (!path) return { db: null, reason:
|
|
65
|
+
const sel = resolveOperatorSnapshot({ envVar: 'AGENTIC_SECURITY_HACKAGE_ADVISORIES', fileName: 'hackage-advisories.json', root, env });
|
|
66
|
+
if (!sel.path) return { db: null, reason: `no advisory snapshot configured (set AGENTIC_SECURITY_HACKAGE_ADVISORIES, or place hackage-advisories.json in the operator configuration directory, agentic-security under XDG_CONFIG_HOME)${sel.projectLocalIgnored ? IGNORED_NOTE('hackage-advisories.json') : ''}`, projectLocalIgnored: !!sel.projectLocalIgnored };
|
|
67
67
|
let snap;
|
|
68
|
-
try { snap = JSON.parse(readFileSync(path, 'utf8')); } catch (e) { return { db: null, reason: `advisory snapshot unreadable: ${e.code || e.message}` }; }
|
|
68
|
+
try { snap = JSON.parse(readFileSync(sel.path, 'utf8')); } catch (e) { return { db: null, reason: `advisory snapshot unreadable: ${e.code || e.message}` }; }
|
|
69
69
|
const r = loadAdvisorySnapshot(snap, { pinnedSha256: env.AGENTIC_SECURITY_HACKAGE_ADVISORIES_SHA256 || null });
|
|
70
|
-
return r.ok ? { db: r.db, reason: null } : { db: null, reason: `advisory snapshot refused: ${r.reason}` };
|
|
70
|
+
return r.ok ? { db: r.db, reason: null, source: sel.source } : { db: null, reason: `advisory snapshot refused: ${r.reason}` };
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/** Imports and import-qualified callees across the project's Haskell sources. */
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Nothing here fetches, builds, evaluates or runs a project's code unless the evaluation is selected AND the sandbox proves its isolation.
|
|
12
12
|
|
|
13
|
-
import { readFileSync
|
|
14
|
-
import {
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { resolveOperatorSnapshot, IGNORED_NOTE } from './trusted-inputs.js';
|
|
15
16
|
import { analyzeHaskellManifests } from './haskell-manifests.js';
|
|
16
17
|
import { buildResolvedGraph } from './haskell-resolved-graph.js';
|
|
17
18
|
import { importNixClosure } from './nix-closure.js';
|
|
@@ -58,6 +59,12 @@ export function resolvedHackageComponents(files) {
|
|
|
58
59
|
const gaps = (g.gaps || []).map((x) => ({ kind: `resolved-${x.kind}`, detail: x.detail || x.message || x.kind, file: r.file }));
|
|
59
60
|
const summary = { file: r.file, source: g.source, graphAvailable: g.graphAvailable, freshness: g.freshness, closure: g.closure, units: g.units ? g.units.length : null, edges: g.edges ? g.edges.length : null };
|
|
60
61
|
if (!g.graphAvailable || g.freshness.status === 'stale') return { components: [], gaps, summary };
|
|
62
|
+
// The plan is a file in the scanned project. Where the project's own freeze file also pins a package, the two must agree: a plan that
|
|
63
|
+
// contradicts the freeze is stale or forged, and contributes no versions.
|
|
64
|
+
const frozen = new Map(((r.manifests && r.manifests.lockedPackages) || []).filter((l) => l.name && l.version).map((l) => [l.name, l.version]));
|
|
65
|
+
const clashes = (g.units || []).filter((u) => u.name && u.version && frozen.has(u.name) && frozen.get(u.name) !== u.version).map((u) => `${u.name} ${u.version} (plan) vs ${frozen.get(u.name)} (freeze)`);
|
|
66
|
+
if (clashes.length) { gaps.push({ kind: 'resolved-stale-plan-contradicts-freeze', detail: `the plan contradicts the freeze file (${clashes.slice(0, 3).join('; ')}): it is stale or forged, so no version was taken from it`, file: r.file }); return { components: [], gaps, summary }; }
|
|
67
|
+
gaps.push({ kind: 'resolved-project-supplied-plan', detail: 'the plan is a file in the scanned project: it is checked against the declared bounds and the freeze file, but whoever controls the project controls its contents', file: r.file });
|
|
61
68
|
const SCOPE = { runtime: 'required', test: 'optional', benchmark: 'optional', setup: 'optional', 'build-tool': 'optional' };
|
|
62
69
|
const direct = new Set((g.declared || []).map((d) => d.name));
|
|
63
70
|
const components = [];
|
|
@@ -71,24 +78,39 @@ export function resolvedHackageComponents(files) {
|
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
// ── Nix closure ──────────────────────────────────────────────────────────────────
|
|
74
|
-
function loadNixExports(files) {
|
|
75
|
-
const exports = []; const sources = []; const problems = [];
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (!parsed.ok) { problems.push({ kind: 'malformed-export', file:
|
|
81
|
+
function loadNixExports(files, env = process.env) {
|
|
82
|
+
const exports = []; const sources = []; const problems = []; const trust = new Set();
|
|
83
|
+
const take = (label, text, origin) => {
|
|
84
|
+
sources.push(label);
|
|
85
|
+
const parsed = parseJson(text);
|
|
86
|
+
if (!parsed.ok) { problems.push({ kind: 'malformed-export', file: label, detail: parsed.reason }); return; }
|
|
80
87
|
const j = parsed.value;
|
|
81
|
-
|
|
88
|
+
trust.add(origin);
|
|
89
|
+
// NOTE: an `expected` block inside the file is IGNORED. The file is the thing under test, so it cannot also say what it should be
|
|
90
|
+
// checked against; what an export must match is derived by the scanner (below) or comes from the operator.
|
|
91
|
+
if (j && Array.isArray(j.exports)) exports.push(...j.exports.filter((e) => e && typeof e === 'object'));
|
|
82
92
|
else if (j && typeof j === 'object' && j.schema && (j.data !== undefined || j.text !== undefined)) exports.push(j);
|
|
83
|
-
else exports.push({ text
|
|
93
|
+
else exports.push({ text });
|
|
94
|
+
};
|
|
95
|
+
if (env.AGENTIC_SECURITY_NIX_EXPORT) {
|
|
96
|
+
try { take(env.AGENTIC_SECURITY_NIX_EXPORT, readFileSync(env.AGENTIC_SECURITY_NIX_EXPORT, 'utf8'), 'operator'); }
|
|
97
|
+
catch (e) { sources.push(env.AGENTIC_SECURITY_NIX_EXPORT); problems.push({ kind: 'malformed-export', file: env.AGENTIC_SECURITY_NIX_EXPORT, detail: `unreadable: ${e.code || e.message}` }); }
|
|
84
98
|
}
|
|
85
|
-
|
|
99
|
+
for (const p of Object.keys(files).filter((x) => NIX_EXPORT.test(x)).sort()) take(p, files[p], 'project-supplied');
|
|
100
|
+
// What the export must have been made against: the real flake.lock of this project (a forged export cannot also forge that file's hash
|
|
101
|
+
// without the lock it claims to describe being the lock that is here).
|
|
102
|
+
const expected = {};
|
|
103
|
+
const lockPath = Object.keys(files).filter((p) => /(^|\/)flake\.lock$/.test(p)).sort((a, b) => a.length - b.length)[0];
|
|
104
|
+
if (lockPath) expected.flakeLockSha256 = createHash('sha256').update(files[lockPath]).digest('hex');
|
|
105
|
+
else if (sources.length) problems.push({ kind: 'export-unverified', detail: 'there is no flake.lock to bind the export to, so it cannot be shown to describe this project' });
|
|
106
|
+
if (env.AGENTIC_SECURITY_NIX_EXPORT_PUBKEY) { try { expected.publicKeyPem = readFileSync(env.AGENTIC_SECURITY_NIX_EXPORT_PUBKEY, 'utf8'); expected.requireSigned = true; } catch { problems.push({ kind: 'malformed-export', detail: 'the configured export public key is unreadable' }); } }
|
|
107
|
+
return { exports, sources, problems, expected, trust: [...trust] };
|
|
86
108
|
}
|
|
87
109
|
|
|
88
110
|
/** The imported closure (and each derivation's env block) for a file set, or null when there is no export. Cached per file set. */
|
|
89
111
|
export function nixClosureOf(files, { now = Date.now() } = {}) {
|
|
90
112
|
return memoized(files, 'nix-closure', () => {
|
|
91
|
-
const { exports, sources, problems, expected } = loadNixExports(files);
|
|
113
|
+
const { exports, sources, problems, expected, trust } = loadNixExports(files);
|
|
92
114
|
if (!sources.length) return null;
|
|
93
115
|
const closure = importNixClosure({ exports, expected, now });
|
|
94
116
|
const drvEnv = {};
|
|
@@ -97,15 +119,19 @@ export function nixClosureOf(files, { now = Date.now() } = {}) {
|
|
|
97
119
|
let data = ex.data; if (data === undefined && typeof ex.text === 'string') { const p = parseJson(ex.text); data = p.ok ? p.value : null; }
|
|
98
120
|
for (const [k, v] of Object.entries(data || {})) if (v && v.env) drvEnv[k] = v.env;
|
|
99
121
|
}
|
|
100
|
-
|
|
122
|
+
// A disclosure that says the export does not describe THIS project (another lock, another target, a bad or missing required
|
|
123
|
+
// signature) means its contents are not evidence about this build: it is refused, not merely labelled.
|
|
124
|
+
const REFUSE = new Set(['stale-export', 'foreign-target', 'foreign-revision', 'invalid-signature', 'unsigned']);
|
|
125
|
+
const refused = (closure.disclosures || []).filter((d) => REFUSE.has(d.kind));
|
|
126
|
+
return { closure, drvEnv, sources, problems, trust, refused };
|
|
101
127
|
});
|
|
102
128
|
}
|
|
103
129
|
|
|
104
130
|
/** Advisory records for Nix: a snapshot named by AGENTIC_SECURITY_NIX_ADVISORIES or kept in the project state directory. */
|
|
105
131
|
export function configuredNixAdvisories(root, env = process.env, hackageDb = null) {
|
|
106
|
-
const
|
|
107
|
-
if (!path) return { data: null, reason:
|
|
108
|
-
let snap; try { snap = JSON.parse(readFileSync(path, 'utf8')); } catch (e) { return { data: null, reason: `the Nix advisory snapshot is unreadable: ${e.code || e.message}` }; }
|
|
132
|
+
const sel = resolveOperatorSnapshot({ envVar: 'AGENTIC_SECURITY_NIX_ADVISORIES', fileName: 'nix-advisories.json', root, env });
|
|
133
|
+
if (!sel.path) return { data: null, reason: `no Nix advisory snapshot is configured (set AGENTIC_SECURITY_NIX_ADVISORIES, or place nix-advisories.json in the operator configuration directory, agentic-security under XDG_CONFIG_HOME)${sel.projectLocalIgnored ? IGNORED_NOTE('nix-advisories.json') : ''}` };
|
|
134
|
+
let snap; try { snap = JSON.parse(readFileSync(sel.path, 'utf8')); } catch (e) { return { data: null, reason: `the Nix advisory snapshot is unreadable: ${e.code || e.message}` }; }
|
|
109
135
|
const records = Array.isArray(snap) ? snap : (Array.isArray(snap.records) ? snap.records : []);
|
|
110
136
|
return { data: new NixAdvisoryData({ records, hackage: hackageDb, source: 'pinned-snapshot', generatedAt: (snap && snap.generatedAt) || null }), reason: null };
|
|
111
137
|
}
|
|
@@ -121,9 +147,11 @@ function _analyzeNixClosure(files, { scanRoot = null, env = process.env, now = D
|
|
|
121
147
|
const adv = configuredNixAdvisories(scanRoot, env, hackage);
|
|
122
148
|
let usage = null; try { usage = collectUsage(files); } catch { usage = null; }
|
|
123
149
|
let matched = null;
|
|
124
|
-
try { matched = matchNixVulnerabilities({ closure: c.closure, drvEnv: c.drvEnv, data: adv.data, overlays: overlayEvidence(files), haskellUsage: usage }); } catch (e) { matched = null; c.problems.push({ kind: 'match-failed', detail: String((e && e.message) || e).slice(0, 160) }); }
|
|
150
|
+
if (!c.refused.length) try { matched = matchNixVulnerabilities({ closure: c.closure, drvEnv: c.drvEnv, data: adv.data, overlays: overlayEvidence(files), haskellUsage: usage }); } catch (e) { matched = null; c.problems.push({ kind: 'match-failed', detail: String((e && e.message) || e).slice(0, 160) }); }
|
|
125
151
|
const gaps = [];
|
|
152
|
+
if (c.trust.includes('project-supplied') && !c.trust.includes('operator')) gaps.push({ kind: 'closure-project-supplied', detail: 'the closure export is a file in the scanned project, bound to its flake.lock but not signed: whoever controls the project controls its contents, so the ABSENCE of a finding for a component it lists is not evidence the real build is free of it (provide the export through AGENTIC_SECURITY_NIX_EXPORT, signed with AGENTIC_SECURITY_NIX_EXPORT_PUBKEY, to have it treated as operator evidence)' });
|
|
126
153
|
for (const d of c.closure.disclosures || []) gaps.push({ kind: `closure-${d.kind}`, detail: d.detail });
|
|
154
|
+
for (const d of c.refused) gaps.push({ kind: `closure-refused-${d.kind}`, detail: `${d.detail}; the export was REFUSED and contributes nothing` });
|
|
127
155
|
for (const p of c.problems) gaps.push({ kind: `closure-${p.kind}`, detail: p.detail, file: p.file });
|
|
128
156
|
if (!adv.data) gaps.push({ kind: 'closure-advisory-feed-unavailable', detail: `${adv.reason}. The closure's components were NOT checked against any advisory: the absence of findings is not a clean result.` });
|
|
129
157
|
else if (adv.data.stale) gaps.push({ kind: 'closure-advisory-feed-stale', detail: `the Nix advisory snapshot is stale or undated (${adv.data.ageDays == null ? 'age unknown' : `${Math.floor(adv.data.ageDays)} day(s) old`})` });
|
|
@@ -24,7 +24,7 @@ export const PAIR_GATE = Object.freeze({ change: 1, preserve: 0.9 });
|
|
|
24
24
|
export const REQUIRED_CAPABILITIES = Object.freeze({
|
|
25
25
|
haskell: Object.freeze({
|
|
26
26
|
parser: 'parse-fixtures', sast: 'sast-holdout', taint: 'taint-holdout', 'privacy-lineage': 'privacy-holdout',
|
|
27
|
-
auth: '
|
|
27
|
+
auth: 'auth-detection', sca: 'sca-fixtures', bom: 'test-suite', fix: 'fix-fixtures', integration: 'test-suite',
|
|
28
28
|
}),
|
|
29
29
|
nix: Object.freeze({
|
|
30
30
|
parser: 'parse-fixtures', 'config-sast': 'sast-holdout', 'config-taint': 'taint-holdout', 'privacy-lineage': 'privacy-holdout',
|
|
@@ -32,6 +32,13 @@ export const REQUIRED_CAPABILITIES = Object.freeze({
|
|
|
32
32
|
}),
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Authentication and authorization are rated on DETECTION of the families that carry them (measured on the frozen holdout, gated like
|
|
37
|
+
* any other layer) AND on the web-model test suite passing. A pass/fail test run alone is not a measurement of how well missing or
|
|
38
|
+
* broken authentication is found, so it can never promote this row by itself.
|
|
39
|
+
*/
|
|
40
|
+
export const AUTH_FAMILIES = Object.freeze(['route-authentication', 'object-authorization']);
|
|
41
|
+
|
|
35
42
|
/** Capabilities that need an external tool or host to be exercised at all: absent, the row is BLOCKED whatever its tests say. */
|
|
36
43
|
export const REQUIRES_TOOL = Object.freeze({
|
|
37
44
|
haskell: Object.freeze({ auth: Object.freeze({ tool: 'ghc', why: 'the route fixtures are compiled by GHC (HS-006.AC01)' }) }),
|
|
@@ -45,6 +52,28 @@ export const REQUIRES_TOOL = Object.freeze({
|
|
|
45
52
|
export const NON_PROMOTING_EVIDENCE = Object.freeze(['installation', 'grammar-present', 'structural-only', 'documentation', 'model-assertion', 'taint-as-privacy', 'unknown']);
|
|
46
53
|
|
|
47
54
|
const ratio = (n, d) => (d > 0 ? n / d : null);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Exact (Clopper-Pearson) two-sided confidence interval for k successes in n trials. A rate of 30/30 is NOT "100%": its 95% lower
|
|
58
|
+
* bound is about 0.88, and a table that prints 1.000 without it invites exactly that misreading.
|
|
59
|
+
*/
|
|
60
|
+
export function clopperPearson(k, n, alpha = 0.05) {
|
|
61
|
+
if (!(n > 0)) return null;
|
|
62
|
+
const binomTail = (p, from) => { // P(X >= from) for X ~ Bin(n, p), computed in log space
|
|
63
|
+
let sum = 0;
|
|
64
|
+
for (let i = from; i <= n; i++) {
|
|
65
|
+
let lc = 0; for (let j = 1; j <= i; j++) lc += Math.log((n - i + j) / j);
|
|
66
|
+
sum += Math.exp(lc + (p > 0 ? i * Math.log(p) : (i === 0 ? 0 : -Infinity)) + (p < 1 ? (n - i) * Math.log(1 - p) : (n - i === 0 ? 0 : -Infinity)));
|
|
67
|
+
}
|
|
68
|
+
return sum;
|
|
69
|
+
};
|
|
70
|
+
const bisect = (f, lo, hi) => { for (let i = 0; i < 80; i++) { const mid = (lo + hi) / 2; if (f(mid)) hi = mid; else lo = mid; } return (lo + hi) / 2; };
|
|
71
|
+
const lower = k === 0 ? 0 : bisect((p) => binomTail(p, k) >= alpha / 2, 0, 1);
|
|
72
|
+
const upper = k === n ? 1 : bisect((p) => binomTail(p, k + 1) >= 1 - alpha / 2, 0, 1);
|
|
73
|
+
return [lower, upper];
|
|
74
|
+
}
|
|
75
|
+
const ci = (k, n) => { const r = clopperPearson(k, n); return r ? [Number(r[0].toFixed(4)), Number(r[1].toFixed(4))] : null; };
|
|
76
|
+
const withInterval = (e) => (e && Number.isFinite(e.tp) ? { ...e, ci: { precision: ci(e.tp, e.tp + (e.fp || 0)), recall: ci(e.tp, e.tp + (e.fn || 0)), level: 0.95, method: 'Clopper-Pearson' } } : e);
|
|
48
77
|
const pct = (x) => (x == null ? 'not measured' : `${(x * 100).toFixed(1)}%`);
|
|
49
78
|
|
|
50
79
|
function rate(layer) {
|
|
@@ -132,6 +161,19 @@ export function evaluateSupport(language, input) {
|
|
|
132
161
|
const u = detection.unknownOutcomes;
|
|
133
162
|
if (u && u.silentClean > 0) reasons.push(`${u.silentClean} unknown case(s) came back with neither a finding nor a disclosure (false assurance)`);
|
|
134
163
|
row(cap, !layer || !layer.measured ? 'not-measured' : reasons.length ? 'failed' : 'supported', reasons, layer ? { metric: kind, layer: key, tp: layer.tp, fp: layer.fp, fn: layer.fn, tn: layer.tn, precision: layer.precision, recall: layer.recall, f1: layer.f1, cases: layer.cases, families: layer.families, strictPrecision: layer.strict && layer.strict.precision } : null);
|
|
164
|
+
} else if (kind === 'auth-detection') {
|
|
165
|
+
const sast = detection.layers && detection.layers.sast;
|
|
166
|
+
const fams = Object.fromEntries(AUTH_FAMILIES.filter((f) => sast && sast.families && sast.families[f]).map((f) => [f, sast.families[f]]));
|
|
167
|
+
if (!Object.keys(fams).length) { row(cap, 'not-measured', [`no measured authentication families (${AUTH_FAMILIES.join(', ')}): a passing test run is not a detection measurement`]); continue; }
|
|
168
|
+
const sum = (k) => Object.values(fams).reduce((n, x) => n + (x[k] || 0), 0);
|
|
169
|
+
const tp = sum('tp'); const fp = sum('fp'); const fn = sum('fn');
|
|
170
|
+
const precision = ratio(tp, tp + fp); const recall = ratio(tp, tp + fn);
|
|
171
|
+
const f1 = precision != null && recall != null && precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : null;
|
|
172
|
+
const pseudo = { measured: true, tp, fp, fn, tn: sum('tn'), precision, recall, f1, families: fams };
|
|
173
|
+
const g = gateLayer(pseudo, filterFamilies(detection.perFamilyCases, pseudo), { kind: 'auth' });
|
|
174
|
+
const ts = suiteStatus(input.suites, (input.capabilitySuites || {})[cap]);
|
|
175
|
+
const reasons = [...g.reasons, ...dataOk.reasons, ...(ts.ok ? [] : ts.reasons)];
|
|
176
|
+
row(cap, reasons.length ? (ts.status === 'blocked' ? 'blocked' : 'failed') : 'supported', reasons, { metric: 'auth-detection', layer: 'sast', families: Object.keys(fams), tp, fp, fn, precision, recall, f1, suites: ((input.capabilitySuites || {})[cap] || []) });
|
|
135
177
|
} else if (kind === 'privacy-holdout') {
|
|
136
178
|
const p = eco.privacy;
|
|
137
179
|
if (!p || !(p.positives > 0 && p.negatives > 0)) { row(cap, 'not-measured', ['no privacy field-to-sink measurement (a security finding or a taint metric is never a privacy metric)']); continue; }
|
|
@@ -168,6 +210,29 @@ export function evaluateSupport(language, input) {
|
|
|
168
210
|
row(cap, ts.status === 'supported' ? 'supported' : ts.status, ts.reasons, { metric: kind, suites: ((input.capabilitySuites || {})[cap] || []) });
|
|
169
211
|
}
|
|
170
212
|
}
|
|
213
|
+
// 95% intervals beside every measured rate, and the strict precision (which counts findings of other families) beside the scoped one
|
|
214
|
+
for (const r of Object.values(out.rows)) {
|
|
215
|
+
if (!r.evidence || !Number.isFinite(r.evidence.tp)) continue;
|
|
216
|
+
const key = /taint/.test(r.capability) ? 'taint' : 'sast';
|
|
217
|
+
const strict = r.evidence.metric === 'sast-holdout' || r.evidence.metric === 'taint-holdout' ? (detection.layers || {})[key] && (detection.layers[key].strict || null) : null;
|
|
218
|
+
r.evidence = withInterval({ ...r.evidence, ...(strict && Number.isFinite(strict.precision) ? { strictPrecision: strict.precision } : {}) });
|
|
219
|
+
}
|
|
220
|
+
// Generalisation: the SAME layers measured on shapes absent from train, validation and holdout. It never changes a row's status (the
|
|
221
|
+
// PRD's gates are defined on the frozen holdout); it is stated beside it, with the shortfalls named, so "supported" is not read as more.
|
|
222
|
+
const un = input.unseen && input.unseen.ecosystems && input.unseen.ecosystems[language] && input.unseen.ecosystems[language].detection;
|
|
223
|
+
out.generalizationGaps = [];
|
|
224
|
+
for (const r of Object.values(out.rows)) {
|
|
225
|
+
if (!r.evidence || !/^(sast|taint)-holdout$/.test(r.evidence.metric || '')) continue;
|
|
226
|
+
const key = /taint/.test(r.capability) ? 'taint' : 'sast';
|
|
227
|
+
const u = un && un.layers && un.layers[key];
|
|
228
|
+
if (!u || !u.measured) { r.generalization = { status: 'not-measured' }; continue; }
|
|
229
|
+
const shortfalls = [];
|
|
230
|
+
if (!(u.precision >= TARGETS.precision)) shortfalls.push(`precision ${pct(u.precision)} < ${pct(TARGETS.precision)}`);
|
|
231
|
+
if (!(u.recall >= TARGETS.recall)) shortfalls.push(`recall ${pct(u.recall)} < ${pct(TARGETS.recall)}`);
|
|
232
|
+
if (!(u.f1 >= TARGETS.f1)) shortfalls.push(`F1 ${pct(u.f1)} < ${pct(TARGETS.f1)}`);
|
|
233
|
+
r.generalization = { status: shortfalls.length ? 'below-target' : 'meets-targets', shortfalls, cases: u.cases, tp: u.tp, fp: u.fp, fn: u.fn, tn: u.tn, precision: u.precision, recall: u.recall, f1: u.f1, strictPrecision: u.strict && u.strict.precision, ci: { precision: ci(u.tp, u.tp + u.fp), recall: ci(u.tp, u.tp + u.fn), level: 0.95, method: 'Clopper-Pearson' } };
|
|
234
|
+
if (shortfalls.length) out.generalizationGaps.push(`${r.capability}: ${shortfalls.join(', ')}`);
|
|
235
|
+
}
|
|
171
236
|
// a capability whose tool is absent is blocked, not passed and not skipped
|
|
172
237
|
const tools = input.tools || {};
|
|
173
238
|
for (const [cap, req] of Object.entries(REQUIRES_TOOL[language] || {})) {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Where an advisory snapshot may come from (trust boundary).
|
|
2
|
+
//
|
|
3
|
+
// An advisory snapshot decides which dependencies are reported as vulnerable. A scanned project is untrusted input: if the project could
|
|
4
|
+
// supply its own snapshot, a pull request could ship an empty, perfectly well-formed, freshly dated one and the scan would report
|
|
5
|
+
// "complete" with the vulnerable dependency gone. So a snapshot is accepted only from the operator:
|
|
6
|
+
// 1. the environment variable named by the caller (a path the operator set), or
|
|
7
|
+
// 2. a file in the operator's per-user configuration directory ($XDG_CONFIG_HOME/agentic-security, or ~/.config/agentic-security),
|
|
8
|
+
// the same directory that holds the per-install signing key.
|
|
9
|
+
// A file of the same name inside the scanned project is never read. It is reported, so the operator knows why it had no effect.
|
|
10
|
+
|
|
11
|
+
import { existsSync, lstatSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { statePath } from '../posture/state-dir.js';
|
|
15
|
+
|
|
16
|
+
/** The operator's per-user configuration directory. */
|
|
17
|
+
export function operatorConfigDir(env = process.env) {
|
|
18
|
+
const base = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.length ? env.XDG_CONFIG_HOME : join(homedir(), '.config');
|
|
19
|
+
return join(base, 'agentic-security');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const isRegularFile = (p) => { try { return lstatSync(p).isFile(); } catch { return false; } };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolves a snapshot path from operator-controlled places only.
|
|
26
|
+
* @returns {{path: string|null, source: 'env'|'operator-config'|null, projectLocalIgnored: string|null}}
|
|
27
|
+
*/
|
|
28
|
+
export function resolveOperatorSnapshot({ envVar, fileName, root = null, env = process.env }) {
|
|
29
|
+
const local = root ? statePath(root, fileName) : null;
|
|
30
|
+
const projectLocalIgnored = local && existsSync(local) ? local : null;
|
|
31
|
+
if (env[envVar]) return { path: env[envVar], source: 'env', projectLocalIgnored };
|
|
32
|
+
const cfg = join(operatorConfigDir(env), fileName);
|
|
33
|
+
if (isRegularFile(cfg)) return { path: cfg, source: 'operator-config', projectLocalIgnored };
|
|
34
|
+
return { path: null, source: null, projectLocalIgnored };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The sentence added to a "no snapshot" reason when the project carried one that was deliberately ignored. */
|
|
38
|
+
export const IGNORED_NOTE = (fileName) => ` A ${fileName} inside the scanned project was IGNORED: a project cannot vouch for its own advisories (provide the snapshot through the environment variable or your per-user configuration directory).`;
|