@clear-capabilities/agentic-security-scanner 0.172.0 → 0.173.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 +29 -0
- package/bin/agentic-security.js +37 -0
- package/dist/119.index.js +197 -0
- package/dist/128.index.js +129 -0
- package/dist/1293.index.js +5689 -0
- package/dist/1336.index.js +101 -101
- package/dist/1351.index.js +133 -0
- package/dist/1387.index.js +271 -0
- package/dist/1437.index.js +23 -0
- package/dist/1445.index.js +110 -0
- package/dist/1505.index.js +151 -0
- package/dist/1557.index.js +293 -0
- package/dist/1621.index.js +311 -0
- package/dist/1751.index.js +160 -0
- package/dist/1765.index.js +727 -0
- package/dist/1861.index.js +690 -0
- package/dist/1898.index.js +116 -0
- package/dist/1906.index.js +433 -0
- package/dist/1920.index.js +1 -291
- package/dist/208.index.js +1631 -0
- package/dist/2199.index.js +0 -239
- package/dist/2223.index.js +159 -0
- package/dist/2530.index.js +152 -0
- package/dist/2565.index.js +116 -0
- package/dist/2574.index.js +127 -0
- package/dist/2620.index.js +345 -0
- package/dist/2773.index.js +98 -0
- package/dist/2775.index.js +32 -0
- package/dist/2966.index.js +171 -0
- package/dist/2972.index.js +87 -0
- package/dist/2991.index.js +131 -0
- package/dist/3063.index.js +833 -0
- package/dist/3075.index.js +227 -0
- package/dist/3121.index.js +297 -0
- package/dist/3314.index.js +263 -0
- package/dist/3480.index.js +1822 -0
- package/dist/3481.index.js +24 -0
- package/dist/3506.index.js +268 -0
- package/dist/3533.index.js +591 -0
- package/dist/3653.index.js +450 -0
- package/dist/3822.index.js +131 -0
- package/dist/3868.index.js +292 -0
- package/dist/3917.index.js +152 -0
- package/dist/3918.index.js +192 -0
- package/dist/4090.index.js +288 -0
- package/dist/4121.index.js +1190 -0
- package/dist/4142.index.js +0 -239
- package/dist/4336.index.js +436 -0
- package/dist/4387.index.js +532 -0
- package/dist/4413.index.js +302 -0
- package/dist/4476.index.js +4510 -0
- package/dist/4491.index.js +133 -0
- package/dist/4538.index.js +185 -0
- package/dist/4684.index.js +319 -0
- package/dist/4740.index.js +165 -0
- package/dist/4780.index.js +209 -0
- package/dist/4802.index.js +225 -0
- package/dist/4812.index.js +379 -0
- package/dist/4835.index.js +126 -0
- package/dist/494.index.js +226 -0
- package/dist/4991.index.js +549 -0
- package/dist/5199.index.js +305 -0
- package/dist/5216.index.js +109 -0
- package/dist/5439.index.js +301 -0
- package/dist/5443.index.js +97 -0
- package/dist/5496.index.js +377 -0
- package/dist/550.index.js +2 -1
- package/dist/5801.index.js +588 -0
- package/dist/5858.index.js +223 -0
- package/dist/5933.index.js +143 -0
- package/dist/601.index.js +23 -0
- package/dist/6173.index.js +273 -0
- package/dist/6291.index.js +1871 -0
- package/dist/6312.index.js +666 -0
- package/dist/634.index.js +16182 -0
- package/dist/644.index.js +87 -0
- package/dist/6508.index.js +283 -0
- package/dist/6560.index.js +645 -0
- package/dist/6576.index.js +422 -0
- package/dist/6579.index.js +271 -0
- package/dist/6745.index.js +305 -0
- package/dist/6818.index.js +21642 -0
- package/dist/6972.index.js +167 -0
- package/dist/6995.index.js +163 -0
- package/dist/7249.index.js +191 -0
- package/dist/7372.index.js +1197 -0
- package/dist/7405.index.js +23 -6
- package/dist/7546.index.js +541 -0
- package/dist/76.index.js +448 -0
- package/dist/7879.index.js +163 -0
- package/dist/8025.index.js +130 -0
- package/dist/8054.index.js +248 -0
- package/dist/8097.index.js +201 -0
- package/dist/8126.index.js +191 -0
- package/dist/8178.index.js +78 -0
- package/dist/818.index.js +212 -0
- package/dist/8382.index.js +93 -0
- package/dist/8408.index.js +261 -0
- package/dist/8540.index.js +174 -0
- package/dist/8635.index.js +802 -0
- package/dist/8688.index.js +410 -0
- package/dist/8699.index.js +779 -0
- package/dist/8891.index.js +457 -0
- package/dist/8916.index.js +885 -0
- package/dist/9146.index.js +170 -0
- package/dist/9168.index.js +841 -0
- package/dist/9292.index.js +369 -0
- package/dist/9382.index.js +338 -48
- package/dist/9422.index.js +866 -0
- package/dist/9440.index.js +453 -0
- package/dist/9479.index.js +152 -0
- package/dist/9481.index.js +220 -0
- package/dist/9539.index.js +100 -0
- package/dist/9547.index.js +103 -0
- package/dist/9560.index.js +41 -61
- package/dist/9692.index.js +90 -0
- package/dist/9800.index.js +213 -1
- package/dist/9899.index.js +138 -0
- package/dist/9981.index.js +206 -0
- package/dist/999.index.js +117 -0
- package/dist/agentic-security.mjs +28 -28
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +31 -6
- package/src/context-intelligence/CLAUDE.md +4 -0
- package/src/context-intelligence/adapters/haskell-hie.js +28 -1
- package/src/context-intelligence/candidates.js +2 -1
- package/src/context-intelligence/canonical.js +25 -0
- package/src/context-intelligence/capabilities.js +2 -1
- package/src/context-intelligence/cli.js +16 -10
- package/src/context-intelligence/config.js +4 -1
- package/src/context-intelligence/controls.js +108 -10
- package/src/context-intelligence/counterexample.js +200 -0
- package/src/context-intelligence/cross-route.js +111 -0
- package/src/context-intelligence/entrypoints.js +2 -2
- package/src/context-intelligence/evaluation/index.js +11 -4
- package/src/context-intelligence/evaluation/scripted-reader.js +12 -2
- package/src/context-intelligence/findings.js +166 -0
- package/src/context-intelligence/invariants.js +197 -0
- package/src/context-intelligence/queue.js +6 -1
- package/src/context-intelligence/reachability.js +10 -4
- package/src/context-intelligence/review.js +129 -16
- package/src/dataflow/access-paths.js +11 -0
- package/src/dataflow/analysis-limits.js +66 -0
- package/src/dataflow/engine.js +272 -20
- package/src/dataflow/service-edges.js +174 -0
- package/src/dataflow/summaries.js +7 -1
- package/src/discovery/judge.js +2 -1
- package/src/engine.js +82 -14
- package/src/fix/apply-fix-service.js +55 -8
- package/src/fix/batch-journal.js +84 -0
- package/src/fix/exploit-replay.js +51 -0
- package/src/fix/footprint.js +65 -0
- package/src/fix/patch-policy.js +49 -0
- package/src/fix/secret-persistence.js +39 -0
- package/src/fix/test-commands.js +84 -0
- package/src/fix/verification-adapters.js +97 -0
- package/src/fix/verification-record.js +243 -0
- package/src/fix/verify-patch.js +89 -0
- package/src/ir/CLAUDE.md +5 -0
- package/src/ir/callgraph.js +23 -1
- package/src/ir/module-resolver.js +315 -0
- package/src/ir/parser-js.js +21 -2
- package/src/ir/semantic-graph.js +245 -0
- package/src/lineage/build-attested-correlation.js +63 -0
- package/src/lineage/deployment-identity.js +76 -0
- package/src/mcp/tools.js +13 -34
- package/src/pipeline/canonical-output.js +64 -0
- package/src/pipeline/detector-runner.js +7 -0
- package/src/pipeline/scan-health.js +14 -0
- package/src/pipeline/supervised-analyzers.js +125 -0
- package/src/pipeline/supervised-child.js +17 -0
- package/src/pipeline/telemetry.js +79 -0
- package/src/posture/accuracy-scorecard.js +98 -9
- package/src/posture/artifact-registry.js +5 -0
- package/src/posture/assurance/evidence-registry.js +215 -0
- package/src/posture/assurance/release-package-verify.js +135 -0
- package/src/posture/assurance/release-package.js +118 -0
- package/src/posture/assurance/release-verdict.js +94 -0
- package/src/posture/assurance/waiver.js +200 -0
- package/src/posture/compliance-truth.js +100 -0
- package/src/posture/evaluation/scan-child.js +3 -1
- package/src/posture/evaluation/why-missed.js +3 -1
- package/src/posture/exploitability-evidence.js +141 -0
- package/src/posture/fix-verify.js +4 -2
- package/src/posture/fleet/exposure-ranking.js +115 -0
- package/src/posture/fleet/service-chain-graph.js +150 -0
- package/src/posture/graph-reconciliation.js +154 -0
- package/src/posture/provenance/coverage.js +103 -0
- package/src/posture/provenance/git-evidence.js +84 -0
- package/src/posture/provenance/origin-resolver.js +7 -1
- package/src/posture/provenance/predicate-replay.js +14 -4
- package/src/posture/runtime-correlation.js +61 -15
- package/src/posture/runtime-explanation.js +116 -0
- package/src/posture/runtime-trace-ingest.js +194 -0
- package/src/posture/scan-checkpoint.js +38 -10
- package/src/posture/waf-ingest.js +7 -2
- package/src/report/index.js +4 -0
- package/src/report/oscal.js +38 -0
- package/src/report/perspectives.js +150 -0
- package/src/sast/_comment-strip.js +8 -1
- package/src/sast/ruby.js +26 -0
- package/src/util/string-memo.js +38 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export const id = 4491;
|
|
2
|
+
export const ids = [4491];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 54491:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
9
|
+
/* harmony export */ extractVulnFunctionsViaLLM: () => (/* binding */ extractVulnFunctionsViaLLM)
|
|
10
|
+
/* harmony export */ });
|
|
11
|
+
/* unused harmony export isLlmScaEnabled */
|
|
12
|
+
/* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(77598);
|
|
13
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(73024);
|
|
14
|
+
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(76760);
|
|
15
|
+
/* harmony import */ var _egress_policy_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(15369);
|
|
16
|
+
// LLM-assisted vulnerable function extraction for SCA findings.
|
|
17
|
+
//
|
|
18
|
+
// For CVEs without OSV ecosystem_specific data or GHSA fix commits,
|
|
19
|
+
// uses an LLM to extract vulnerable function names from the CVE description.
|
|
20
|
+
//
|
|
21
|
+
// Gated behind AGENTIC_SECURITY_LLM_SCA=1 (opt-in).
|
|
22
|
+
// Uses the same LLM endpoint config as the SAST validator.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
const CACHE_DIR = process.env.XDG_CONFIG_HOME
|
|
30
|
+
? node_path__WEBPACK_IMPORTED_MODULE_2__.join(process.env.XDG_CONFIG_HOME, 'agentic-security', 'llm-sca-cache')
|
|
31
|
+
: node_path__WEBPACK_IMPORTED_MODULE_2__.join(process.env.HOME || '/tmp', '.config', 'agentic-security', 'llm-sca-cache');
|
|
32
|
+
|
|
33
|
+
function _cacheKey(osvId) {
|
|
34
|
+
return node_crypto__WEBPACK_IMPORTED_MODULE_0__.createHash('sha256').update(`sca-fn:${osvId}`).digest('hex').slice(0, 16);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function _readCache(osvId) {
|
|
38
|
+
try {
|
|
39
|
+
const fp = node_path__WEBPACK_IMPORTED_MODULE_2__.join(CACHE_DIR, _cacheKey(osvId) + '.json');
|
|
40
|
+
return JSON.parse(node_fs__WEBPACK_IMPORTED_MODULE_1__.readFileSync(fp, 'utf8'));
|
|
41
|
+
} catch { return null; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function _writeCache(osvId, data) {
|
|
45
|
+
try {
|
|
46
|
+
node_fs__WEBPACK_IMPORTED_MODULE_1__.mkdirSync(CACHE_DIR, { recursive: true });
|
|
47
|
+
node_fs__WEBPACK_IMPORTED_MODULE_1__.writeFileSync(node_path__WEBPACK_IMPORTED_MODULE_2__.join(CACHE_DIR, _cacheKey(osvId) + '.json'), JSON.stringify(data));
|
|
48
|
+
} catch { /* cache write failure is non-fatal */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isLlmScaEnabled() {
|
|
52
|
+
return process.env.AGENTIC_SECURITY_LLM_SCA === '1';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _endpointConfig() {
|
|
56
|
+
const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
|
|
57
|
+
const apiKey = process.env.AGENTIC_SECURITY_LLM_API_KEY;
|
|
58
|
+
const model = process.env.AGENTIC_SECURITY_LLM_MODEL || 'unknown';
|
|
59
|
+
return endpoint ? { endpoint, apiKey, model } : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function _askLlm(prompt, config) {
|
|
63
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
64
|
+
if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`;
|
|
65
|
+
const resp = await fetch(config.endpoint, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers,
|
|
68
|
+
body: JSON.stringify({ prompt, model: config.model }),
|
|
69
|
+
signal: AbortSignal.timeout(15000),
|
|
70
|
+
});
|
|
71
|
+
if (!resp.ok) return null;
|
|
72
|
+
const text = await resp.text();
|
|
73
|
+
const jsonMatch = text.match(/\{[^{}]*"functions"\s*:\s*\[[^\]]*\][^{}]*\}/);
|
|
74
|
+
if (!jsonMatch) return null;
|
|
75
|
+
try { return JSON.parse(jsonMatch[0]); } catch { return null; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function extractVulnFunctionsViaLLM(supplyChain, opts = {}) {
|
|
79
|
+
if (!isLlmScaEnabled()) return [];
|
|
80
|
+
const config = _endpointConfig();
|
|
81
|
+
if (!config) return [];
|
|
82
|
+
// FR-601: one decision for the whole batch (same endpoint for every CVE in
|
|
83
|
+
// this run) — evaluated before any of the per-candidate prompts below are
|
|
84
|
+
// built.
|
|
85
|
+
const egressDecision = (0,_egress_policy_js__WEBPACK_IMPORTED_MODULE_3__/* .evaluateEgress */ .nn)({ scanRoot: opts.scanRoot, purpose: 'sca-llm-function-extract', endpoint: config.endpoint });
|
|
86
|
+
if (!egressDecision.allowed) return [];
|
|
87
|
+
|
|
88
|
+
const enriched = [];
|
|
89
|
+
const candidates = (supplyChain || []).filter(sc =>
|
|
90
|
+
sc.type === 'vulnerable_dep' &&
|
|
91
|
+
(!sc.osvVulnFunctions || !sc.osvVulnFunctions.length) &&
|
|
92
|
+
sc.noKnownCallSite &&
|
|
93
|
+
sc.description
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const BATCH_LIMIT = 20;
|
|
97
|
+
for (const sc of candidates.slice(0, BATCH_LIMIT)) {
|
|
98
|
+
const cached = _readCache(sc.osvId);
|
|
99
|
+
if (cached) {
|
|
100
|
+
if (cached.functions && cached.functions.length) {
|
|
101
|
+
sc.osvVulnFunctions = cached.functions;
|
|
102
|
+
sc._llmFunctionExtracted = true;
|
|
103
|
+
enriched.push(sc);
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const prompt = `Given security advisory ${sc.osvId || ''} (${sc.cveAliases?.[0] || ''}) affecting npm package "${sc.name}" version ${sc.version}:\n\nDescription: ${sc.description.slice(0, 500)}\n\nWhat specific exported function(s) in this package are vulnerable? Return ONLY a JSON object: { "functions": ["functionName1", "functionName2"] }\n\nIf you cannot determine the specific functions, return: { "functions": [] }`;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const result = await _askLlm(prompt, config);
|
|
112
|
+
if (result && Array.isArray(result.functions)) {
|
|
113
|
+
const fns = result.functions.filter(f => typeof f === 'string' && f.length > 0 && f.length < 100);
|
|
114
|
+
_writeCache(sc.osvId, { functions: fns, model: config.model, extractedAt: new Date().toISOString() });
|
|
115
|
+
if (fns.length) {
|
|
116
|
+
sc.osvVulnFunctions = fns;
|
|
117
|
+
sc._llmFunctionExtracted = true;
|
|
118
|
+
enriched.push(sc);
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
_writeCache(sc.osvId, { functions: [], model: config.model, extractedAt: new Date().toISOString() });
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// LLM call failure — skip, don't cache (may be transient)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return enriched;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
/***/ })
|
|
132
|
+
|
|
133
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
export const id = 4538;
|
|
2
|
+
export const ids = [4538];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 74538:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
9
|
+
/* harmony export */ proposeGovernanceEdit: () => (/* binding */ proposeGovernanceEdit)
|
|
10
|
+
/* harmony export */ });
|
|
11
|
+
/* harmony import */ var _recipient_registry_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(16288);
|
|
12
|
+
// governance-edit.js — M5 deliverable #5 (PRD line 1324's 5-part write
|
|
13
|
+
// contract: preview, validation, backup/version guard, confirmation,
|
|
14
|
+
// audit event). A CLI-only workflow for proposing a validated,
|
|
15
|
+
// reviewable edit to recipient-profiles.json — the one governance
|
|
16
|
+
// config file this codebase already has real, tested per-entry
|
|
17
|
+
// validation for (isValidRecipientConfigEntry, recipient-registry.js).
|
|
18
|
+
//
|
|
19
|
+
// Deliberately narrower than the PRD's own richer "interactive review/
|
|
20
|
+
// approve UI" vision — the HTTP-server-side interactive write surface
|
|
21
|
+
// (new routes, CSRF protection, a write-authorization mechanism beyond
|
|
22
|
+
// the existing read-only session token) is real, separately-scoped
|
|
23
|
+
// future work, not attempted here. See this sub-project's own scoping
|
|
24
|
+
// doc for the full reasoning: no PRD acceptance criterion gates this
|
|
25
|
+
// deliverable at all, and every M4/M5 deliverable this session has
|
|
26
|
+
// shipped has been CLI-first with zero UI/HTTP-write work.
|
|
27
|
+
//
|
|
28
|
+
// This module is pure — no file I/O, no fs access. The CLI layer
|
|
29
|
+
// (bin/agentic-security.js's cmdGovernancePropose) owns reading the
|
|
30
|
+
// current file, writing the backup, writing the new content, and
|
|
31
|
+
// calling auditCall.
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
// Validates the top-level `{recipients: {...}}` container shape — used
|
|
36
|
+
// for BOTH the current on-disk config and the --patch file. A patch is
|
|
37
|
+
// user input the command's whole job is to validate, so it gets no
|
|
38
|
+
// tolerant degradation (fixes I2/I3: previously a patch missing
|
|
39
|
+
// `recipients` entirely, or with `recipients` as an array, degraded
|
|
40
|
+
// silently to an empty no-op write). The CURRENT config gets the same
|
|
41
|
+
// check for a different reason: if its top-level shape is unrecognized
|
|
42
|
+
// (e.g. a typo'd `Recipients` key), the tool must refuse rather than
|
|
43
|
+
// silently treating "no recognizable data" as "start from empty" and
|
|
44
|
+
// overwriting whatever WAS there (this was B1's second live repro —
|
|
45
|
+
// the whole file being replaced under a misspelled key).
|
|
46
|
+
function _validateContainerShape(recipients, label) {
|
|
47
|
+
if (recipients === undefined) {
|
|
48
|
+
return [{ key: '(top-level)', message: `${label} is missing a "recipients" object` }];
|
|
49
|
+
}
|
|
50
|
+
if (recipients === null || typeof recipients !== 'object' || Array.isArray(recipients)) {
|
|
51
|
+
return [{ key: '(top-level)', message: `${label}'s "recipients" must be a plain object, not ${Array.isArray(recipients) ? 'an array' : recipients === null ? 'null' : typeof recipients}` }];
|
|
52
|
+
}
|
|
53
|
+
const errors = [];
|
|
54
|
+
for (const key of Object.keys(recipients)) {
|
|
55
|
+
if (key.length === 0) errors.push({ key, message: `${label} contains an empty-string recipient key, which is never valid` });
|
|
56
|
+
if (key === '__proto__') errors.push({ key, message: `${label} contains a "__proto__" recipient key, which is never valid` });
|
|
57
|
+
}
|
|
58
|
+
return errors;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _validateEntries(patchRecipients) {
|
|
62
|
+
const errors = [];
|
|
63
|
+
for (const [key, entry] of Object.entries(patchRecipients)) {
|
|
64
|
+
if (entry === null) continue; // explicit deletion marker, not a config entry
|
|
65
|
+
if (!(0,_recipient_registry_js__WEBPACK_IMPORTED_MODULE_0__/* .isValidRecipientConfigEntry */ .cw)(entry)) {
|
|
66
|
+
errors.push({ key, message: `recipient "${key}" is not a valid recipient-profile-shaped config entry` });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return errors;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// RFC-7396-style merge patch at the recipient-key level: a non-null
|
|
73
|
+
// patch value REPLACES that key's entire entry; a null value DELETES
|
|
74
|
+
// it; a key the patch never mentions is left untouched. This is the
|
|
75
|
+
// fix for a real data-loss bug (found by this task's own review,
|
|
76
|
+
// live-reproduced): the prior design treated `patch.recipients` as
|
|
77
|
+
// the ENTIRE new recipients object, so omitting a key silently
|
|
78
|
+
// deleted it — a reasonable operator adding one vendor had no reason
|
|
79
|
+
// to expect every other vendor's compliance facts to vanish.
|
|
80
|
+
function _mergeRecipients(currentRecipients, patchRecipients) {
|
|
81
|
+
const merged = { ...currentRecipients };
|
|
82
|
+
for (const [key, value] of Object.entries(patchRecipients)) {
|
|
83
|
+
if (value === null) delete merged[key];
|
|
84
|
+
else merged[key] = value;
|
|
85
|
+
}
|
|
86
|
+
return merged;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Canonical (key-order-independent) JSON serialization for change
|
|
90
|
+
// detection — mirrors ids.js's own _canon precedent. A hand-authored
|
|
91
|
+
// patch file has no reason to preserve the stored config's own key
|
|
92
|
+
// order, so a raw JSON.stringify comparison would spuriously flag a
|
|
93
|
+
// semantically-unchanged recipient as "changed" (found by the task
|
|
94
|
+
// review, reproduced live). Arrays keep their own order (order is
|
|
95
|
+
// semantically meaningful there — e.g. subprocessorChain); only OBJECT
|
|
96
|
+
// key order is normalized.
|
|
97
|
+
function _canonicalize(value) {
|
|
98
|
+
if (Array.isArray(value)) return value.map(_canonicalize);
|
|
99
|
+
if (value && typeof value === 'object') {
|
|
100
|
+
const sorted = {};
|
|
101
|
+
for (const key of Object.keys(value).sort()) sorted[key] = _canonicalize(value[key]);
|
|
102
|
+
return sorted;
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function _diffRecipients(currentRecipients, patchRecipients) {
|
|
108
|
+
const added = [];
|
|
109
|
+
const removed = [];
|
|
110
|
+
const changed = [];
|
|
111
|
+
const currentKeys = new Set(Object.keys(currentRecipients));
|
|
112
|
+
for (const [key, value] of Object.entries(patchRecipients)) {
|
|
113
|
+
if (value === null) {
|
|
114
|
+
if (currentKeys.has(key)) removed.push(key);
|
|
115
|
+
// deleting a key that never existed is a no-op, not reported
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!currentKeys.has(key)) { added.push(key); continue; }
|
|
119
|
+
if (JSON.stringify(_canonicalize(currentRecipients[key])) !== JSON.stringify(_canonicalize(value))) {
|
|
120
|
+
changed.push({ key, before: currentRecipients[key], after: value });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
added.sort();
|
|
124
|
+
removed.sort();
|
|
125
|
+
changed.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
126
|
+
return { added, removed, changed };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Propose a patch to a recipient-profiles.json-shaped config. Pure,
|
|
131
|
+
* never throws, never touches the filesystem. `currentConfig`/`patch`
|
|
132
|
+
* are both expected to be `{recipients: {...}}`-shaped — a malformed
|
|
133
|
+
* top-level shape on EITHER side (missing `recipients`, `recipients`
|
|
134
|
+
* not a plain object, or an empty-string/`__proto__` recipient key) is
|
|
135
|
+
* a validation failure (`valid: false`, `merged: null`), never a
|
|
136
|
+
* silent empty-object fallback. The stored config gets this treatment
|
|
137
|
+
* so an unrecognized shape (e.g. a typo'd top-level key) refuses to
|
|
138
|
+
* write rather than being silently treated as "nothing here yet" and
|
|
139
|
+
* overwritten; the patch gets it because it is user input the command's
|
|
140
|
+
* whole job is to validate.
|
|
141
|
+
*
|
|
142
|
+
* `patch.recipients` is an RFC-7396-style JSON MERGE PATCH against
|
|
143
|
+
* `currentConfig.recipients`, keyed at the recipient level — never a
|
|
144
|
+
* full replacement of the whole object: a key present with a non-null
|
|
145
|
+
* value REPLACES that key's entire entry (never deep-merged within
|
|
146
|
+
* itself); a key present with value `null` DELETES it (the only way to
|
|
147
|
+
* remove a recipient); a key the patch never mentions is left
|
|
148
|
+
* untouched in the merged result. Every OTHER top-level key on the
|
|
149
|
+
* current config (e.g. `$schema`, `version`) is preserved verbatim into
|
|
150
|
+
* `merged` — only `recipients` itself is merged.
|
|
151
|
+
*
|
|
152
|
+
* Returns `{valid, errors, diff, merged}` — `diff`/`merged` are always
|
|
153
|
+
* computed when both container shapes are valid, even when `valid` is
|
|
154
|
+
* false due to a per-entry error, so an operator can see what they
|
|
155
|
+
* attempted before fixing a validation error. `merged` is `null` only
|
|
156
|
+
* when there is no safe merge base to compute (a container-shape
|
|
157
|
+
* failure on either side). `merged`, when non-null, is the full
|
|
158
|
+
* config-shaped result the caller should write — never the raw patch.
|
|
159
|
+
*/
|
|
160
|
+
function proposeGovernanceEdit(currentConfig, patch) {
|
|
161
|
+
const currentContainerErrors = _validateContainerShape(currentConfig?.recipients, 'the current config file');
|
|
162
|
+
const patchContainerErrors = _validateContainerShape(patch?.recipients, 'the --patch file');
|
|
163
|
+
if (currentContainerErrors.length || patchContainerErrors.length) {
|
|
164
|
+
return {
|
|
165
|
+
valid: false,
|
|
166
|
+
errors: [...currentContainerErrors, ...patchContainerErrors],
|
|
167
|
+
diff: { added: [], removed: [], changed: [] },
|
|
168
|
+
merged: null,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const currentRecipients = currentConfig.recipients;
|
|
172
|
+
const patchRecipients = patch.recipients;
|
|
173
|
+
const entryErrors = _validateEntries(patchRecipients);
|
|
174
|
+
const diff = _diffRecipients(currentRecipients, patchRecipients);
|
|
175
|
+
const mergedRecipients = _mergeRecipients(currentRecipients, patchRecipients);
|
|
176
|
+
// Preserve every OTHER top-level key from the current config verbatim
|
|
177
|
+
// (e.g. $schema, version) — only `recipients` itself is merged.
|
|
178
|
+
const merged = { ...(currentConfig && typeof currentConfig === 'object' ? currentConfig : {}), recipients: mergedRecipients };
|
|
179
|
+
return { valid: entryErrors.length === 0, errors: entryErrors, diff, merged };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
/***/ })
|
|
184
|
+
|
|
185
|
+
};
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
export const id = 4684;
|
|
2
|
+
export const ids = [4684];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 34684:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// EXPORTS
|
|
10
|
+
__webpack_require__.d(__webpack_exports__, {
|
|
11
|
+
analyzeLanguageFindings: () => (/* binding */ analyzeLanguageFindings),
|
|
12
|
+
analyzeLanguageSupplyChain: () => (/* binding */ analyzeLanguageSupplyChain)
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// UNUSED EXPORTS: nixosEntries
|
|
16
|
+
|
|
17
|
+
// EXTERNAL MODULE: ./src/language/resolved-pass.js + 4 modules
|
|
18
|
+
var resolved_pass = __webpack_require__(30208);
|
|
19
|
+
// EXTERNAL MODULE: ./src/language/nix-build-trust.js
|
|
20
|
+
var nix_build_trust = __webpack_require__(13787);
|
|
21
|
+
// EXTERNAL MODULE: ./src/language/discovery.js
|
|
22
|
+
var discovery = __webpack_require__(41928);
|
|
23
|
+
// EXTERNAL MODULE: ./src/language/haskell-supply.js
|
|
24
|
+
var haskell_supply = __webpack_require__(67052);
|
|
25
|
+
// EXTERNAL MODULE: ./src/language/nix-script-taint.js
|
|
26
|
+
var nix_script_taint = __webpack_require__(89928);
|
|
27
|
+
// EXTERNAL MODULE: ./src/language/nix-secrets.js
|
|
28
|
+
var nix_secrets = __webpack_require__(2245);
|
|
29
|
+
// EXTERNAL MODULE: ./src/language/nix-parser.js
|
|
30
|
+
var nix_parser = __webpack_require__(81985);
|
|
31
|
+
// EXTERNAL MODULE: ./src/language/secrets.js
|
|
32
|
+
var secrets = __webpack_require__(67855);
|
|
33
|
+
// EXTERNAL MODULE: ./src/language/haskell-manifests.js
|
|
34
|
+
var haskell_manifests = __webpack_require__(60377);
|
|
35
|
+
;// CONCATENATED MODULE: ./src/language/nix-agents.js
|
|
36
|
+
// Nix-launched agent and MCP service configuration (X-003).
|
|
37
|
+
//
|
|
38
|
+
// What this reads is DECLARATION: a systemd service or container whose command line starts an MCP server or
|
|
39
|
+
// an agent CLI, with the filesystem roots, flags, bind address, user and environment the configuration gives
|
|
40
|
+
// it. Every finding here is configuration evidence of a CAPABILITY, never proof that an agent exercised it
|
|
41
|
+
// (`declaredCapability: true`, `exercised: 'not-established'`). Bridges to the application that reads the
|
|
42
|
+
// configuration (an environment variable the Nix service sets and the Haskell program reads) carry explicit
|
|
43
|
+
// provenance on both sides, and only become `executable-linked` when the service launches an executable the
|
|
44
|
+
// project's own manifest builds; a matching variable name alone is a `candidate`.
|
|
45
|
+
//
|
|
46
|
+
// Static only: nothing is evaluated or launched.
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
const NIX_AGENTS_VERSION = 'nix-agents/1';
|
|
53
|
+
const PH = '';
|
|
54
|
+
|
|
55
|
+
const MCP_RE = /(?:@modelcontextprotocol\/|\bmcp[-_]server|\bserver[-_]mcp|\bmcp[-_]proxy|fastmcp|\buvx\s+mcp|\bnpx\b[^\n]*\bmcp\b|\s--mcp\b)/i;
|
|
56
|
+
const SHELL_MCP_RE = /(?:mcp[-_]server[-_](?:shell|commands|exec)|server-shell|shell-server|mcp-shell|mcp[-_]commands)/i;
|
|
57
|
+
const AGENT_RE = /(?:^|[/\s])(?:claude|aider|goose|codex|gemini|gemini-cli|opencode|crush|openhands|open-interpreter|interpreter|autogpt|cline)(?:\s|$|--)/i;
|
|
58
|
+
const AUTO_APPROVE_RE = /(?:--dangerously-skip-permissions|--dangerously-bypass[\w-]*|--yolo\b|--auto-?approve\b|--approval-mode[= ]yolo|--no-confirm\b|--yes-always\b|--permission-mode[= ]bypass\w*|-y\s+--(?:auto|run))/i;
|
|
59
|
+
const BIND_ALL_RE = /(?:--(?:host|listen|address|bind)[= ]\s*(?:0\.0\.0\.0|::|\[::\]|\*)|-H\s+0\.0\.0\.0|\b(?:HOST|LISTEN_ADDR|BIND(?:_ADDRESS)?)=0\.0\.0\.0)/i;
|
|
60
|
+
const BROAD_ROOTS = [[/(?:^|\s)\/(?:\s|$)/, '/', 'high'], [/(?:^|\s)\/(?:etc|root|nix|boot|proc|sys|usr)(?:\/\S*)?(?:\s|$)/, '/etc, /root, /nix or another system tree', 'high'], [/(?:^|\s)(?:\/home|\/var|\/mnt|\/srv\/?)(?:\s|$)/, '/home, /var or /mnt', 'medium'], [/(?:^|\s)(?:~\/?|\$HOME\/?|%h\/?)(?:\s|$)/, 'the user home directory', 'medium']];
|
|
61
|
+
|
|
62
|
+
const segName = (s) => (s && s.kind === 'static' ? s.name : null);
|
|
63
|
+
const unparen = (n) => { let x = n; while (x && x.type === 'paren') x = x.expr; return x; };
|
|
64
|
+
|
|
65
|
+
/** A string's text with every interpolation replaced by a placeholder (never evaluated). */
|
|
66
|
+
function textOf(n) {
|
|
67
|
+
n = unparen(n);
|
|
68
|
+
if (!n) return null;
|
|
69
|
+
if (n.type === 'string') return n.parts.map((p) => (p.kind === 'text' ? p.value : PH)).join('');
|
|
70
|
+
if (n.type === 'list') return n.items.map((i) => textOf(i)).filter((x) => x !== null).join(' ');
|
|
71
|
+
if (n.type === 'path') return n.literal || null;
|
|
72
|
+
if (n.type === 'binop' && n.op === '+') { const a = textOf(n.left); const b = textOf(n.right); return a !== null && b !== null ? a + b : null; }
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function collectServices(files) {
|
|
77
|
+
const services = new Map();
|
|
78
|
+
const get = (file, name, kind, line) => { const k = `${kind}:${name}`; if (!services.has(k)) services.set(k, { file, name, kind, line, commands: [], env: {}, user: null, dynamicUser: null, openFirewall: null, extra: {} }); return services.get(k); };
|
|
79
|
+
for (const [file, text] of Object.entries(files)) {
|
|
80
|
+
const parse = (0,nix_parser/* parseNix */.Z$)(text, { file });
|
|
81
|
+
if (!parse.ast) continue;
|
|
82
|
+
const stack = [{ n: parse.ast, path: [] }];
|
|
83
|
+
while (stack.length) {
|
|
84
|
+
const { n, path } = stack.pop();
|
|
85
|
+
if (!n || typeof n !== 'object') continue;
|
|
86
|
+
if (n.type === 'attrset') {
|
|
87
|
+
for (const b of n.bindings || []) {
|
|
88
|
+
if (b.kind !== 'attr' || !b.value) continue;
|
|
89
|
+
const key = (b.path || []).map(segName);
|
|
90
|
+
if (key.some((x) => x === null)) { stack.push({ n: b.value, path: [...path, ...key.filter(Boolean)] }); continue; }
|
|
91
|
+
const full = [...path, ...key];
|
|
92
|
+
const line = b.span ? b.span.startLine : null;
|
|
93
|
+
let m = /^systemd\.(?:user\.)?services\.([^.]+)\.(.+)$/.exec(full.join('.'));
|
|
94
|
+
let kind = 'systemd';
|
|
95
|
+
const oci = /^virtualisation\.oci-containers\.containers\.([^.]+)\.(.+)$/.exec(full.join('.'));
|
|
96
|
+
if (oci) { m = oci; kind = 'oci'; }
|
|
97
|
+
if (m) {
|
|
98
|
+
const svc = get(file, m[1], kind, line); const rest = m[2];
|
|
99
|
+
const v = unparen(b.value); const t = textOf(v);
|
|
100
|
+
if (kind === 'systemd') {
|
|
101
|
+
if ((/^(?:script|preStart|postStart)$/.test(rest) || /^serviceConfig\.ExecStart(?:Pre)?$/.test(rest)) && t !== null) svc.commands.push({ text: t, line, file, option: rest });
|
|
102
|
+
if (rest === 'serviceConfig.User' && t !== null) svc.user = { value: t, line };
|
|
103
|
+
if (rest === 'serviceConfig.DynamicUser' && v && v.type === 'ident') svc.dynamicUser = { value: v.name === 'true', line };
|
|
104
|
+
const env = /^environment\.(.+)$/.exec(rest);
|
|
105
|
+
if (env && t !== null) svc.env[env[1]] = { text: t, line, file };
|
|
106
|
+
if (rest === 'environment' && v && v.type === 'attrset') for (const eb of v.bindings) { if (eb.kind !== 'attr') continue; const ek = (eb.path || []).map(segName).filter(Boolean).join('.'); const et = textOf(eb.value); if (et !== null) svc.env[ek] = { text: et, line: eb.span ? eb.span.startLine : line, file }; }
|
|
107
|
+
} else {
|
|
108
|
+
if (/^(?:cmd|entrypoint)$/.test(rest) && t !== null) svc.commands.push({ text: t, line, file, option: rest });
|
|
109
|
+
if (rest === 'image' && t !== null) svc.extra.image = t;
|
|
110
|
+
if (rest === 'user' && t !== null) svc.user = { value: t, line };
|
|
111
|
+
if (rest === 'ports' && t !== null) svc.extra.ports = t;
|
|
112
|
+
if (rest === 'volumes' && t !== null) svc.extra.volumes = t;
|
|
113
|
+
const env = /^environment\.(.+)$/.exec(rest);
|
|
114
|
+
if (env && t !== null) svc.env[env[1]] = { text: t, line, file };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
stack.push({ n: b.value, path: full });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const k of Object.keys(n)) { if (k === 'span') continue; const c = n[k]; if (c && typeof c === 'object' && !(n.type === 'attrset' && k === 'bindings')) { if (Array.isArray(c)) for (const x of c) stack.push({ n: x, path }); else stack.push({ n: c, path }); } }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return [...services.values()];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const finding = (rule, svc, cmd, o) => ({
|
|
127
|
+
id: `${rule}:${svc.file}:${o.line || svc.line}`, file: svc.file, line: o.line || svc.line, parser: 'NIX-AGENT', family: 'agent-config', rule,
|
|
128
|
+
severity: o.severity, cwe: o.cwe, vuln: o.vuln, owaspLlm: 'LLM06',
|
|
129
|
+
description: `${o.desc} This is configuration evidence of a declared capability; nothing shows an agent has exercised it.`,
|
|
130
|
+
remediation: o.fix, language: 'nix', capability: 'iac', analysisKind: 'configuration', evidenceKind: 'config', scope: 'system', confidence: o.confidence ?? 0.7,
|
|
131
|
+
declaredCapability: true, exercised: 'not-established', service: { name: svc.name, kind: svc.kind }, subject: `${svc.kind}:${svc.name}`,
|
|
132
|
+
attrPath: `${svc.kind === 'oci' ? 'virtualisation.oci-containers.containers' : 'systemd.services'}.${svc.name}`,
|
|
133
|
+
chain: [{ file: svc.file, line: o.line || svc.line, label: o.label || o.vuln, kind: 'config' }],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {Record<string,string>} files Nix sources (and, for bridges, Haskell sources and .cabal manifests)
|
|
138
|
+
*/
|
|
139
|
+
function analyzeNixAgents(files) {
|
|
140
|
+
const nix = Object.fromEntries(Object.entries(files).filter(([p, t]) => /\.nix$/i.test(p) && typeof t === 'string'));
|
|
141
|
+
const findings = []; const agents = []; const bridges = [];
|
|
142
|
+
const services = collectServices(nix);
|
|
143
|
+
for (const svc of services) {
|
|
144
|
+
const all = svc.commands.map((c) => c.text).join('\n');
|
|
145
|
+
const isMcp = MCP_RE.test(all) || MCP_RE.test(svc.name) || (svc.extra.image && MCP_RE.test(svc.extra.image));
|
|
146
|
+
const isAgent = AGENT_RE.test(all);
|
|
147
|
+
if (!isMcp && !isAgent) continue;
|
|
148
|
+
const cmd = svc.commands.find((c) => MCP_RE.test(c.text) || AGENT_RE.test(c.text)) || svc.commands[0] || { text: '', line: svc.line };
|
|
149
|
+
const entry = { name: svc.name, kind: isMcp ? 'mcp-server' : 'agent', launcher: svc.kind, file: svc.file, line: cmd.line || svc.line, user: svc.user ? svc.user.value : null, dynamicUser: svc.dynamicUser ? svc.dynamicUser.value : null, env: Object.keys(svc.env), declaredCapability: true, exercised: 'not-established' };
|
|
150
|
+
agents.push(entry);
|
|
151
|
+
const text = ` ${all.replace(new RegExp(PH, 'g'), ' ')} `;
|
|
152
|
+
if (SHELL_MCP_RE.test(all)) findings.push(finding('nix-mcp-shell-server', svc, cmd, { severity: 'high', cwe: 'CWE-250', vuln: 'An MCP server that executes shell commands is launched as a service', desc: `${svc.name} starts an MCP server whose purpose is to run commands.`, fix: 'Do not expose a general shell to a model. Offer narrow tools, or run it in a sandbox with no network and a read-only root.', line: cmd.line }));
|
|
153
|
+
if (isMcp) for (const [re, what, sev] of BROAD_ROOTS) if (re.test(text)) { findings.push(finding('nix-mcp-broad-filesystem', svc, cmd, { severity: sev, cwe: 'CWE-732', vuln: 'An MCP/agent service is given a broad filesystem root', desc: `${svc.name} is started with ${what} as a root the server may read or write.`, fix: 'Point the server at the one project directory it needs, and add systemd ReadWritePaths/ProtectSystem so the unit cannot reach more.', line: cmd.line })); break; }
|
|
154
|
+
if (AUTO_APPROVE_RE.test(text)) findings.push(finding('nix-agent-auto-approve', svc, cmd, { severity: 'high', cwe: 'CWE-862', vuln: 'An agent is launched with permission prompts disabled', desc: `${svc.name} starts an agent with an auto-approve / skip-permissions flag, so tool use needs no confirmation.`, fix: 'Remove the flag, or run the agent in an isolated unit with a minimal allow-list.', line: cmd.line }));
|
|
155
|
+
const bindAll = BIND_ALL_RE.test(text) || Object.values(svc.env).some((e) => BIND_ALL_RE.test(` ${e.text} `) || /^0\.0\.0\.0$/.test(String(e.text).trim()) && false);
|
|
156
|
+
const envBind = Object.entries(svc.env).find(([k, e]) => /^(?:HOST|LISTEN_ADDR|BIND|BIND_ADDRESS|MCP_HOST)$/i.test(k) && String(e.text).trim() === '0.0.0.0');
|
|
157
|
+
if (bindAll || envBind) findings.push(finding('nix-agent-network-exposed', svc, cmd, { severity: 'medium', cwe: 'CWE-668', vuln: 'An agent/MCP service listens on every interface', desc: `${svc.name} binds 0.0.0.0, so anything that can reach the host can reach the tool surface.`, fix: 'Bind 127.0.0.1 (or a unix socket) and put authentication in front of anything that must be remote.', line: envBind ? envBind[1].line : cmd.line }));
|
|
158
|
+
const root = svc.kind === 'systemd' && (!svc.user || svc.user.value === 'root') && !(svc.dynamicUser && svc.dynamicUser.value);
|
|
159
|
+
if (root) findings.push(finding('nix-agent-runs-as-root', svc, cmd, { severity: 'medium', cwe: 'CWE-250', vuln: 'An agent/MCP service runs as root', desc: `${svc.name} has no User or DynamicUser, so systemd runs it as root.`, fix: 'Set serviceConfig.DynamicUser = true (or a dedicated User) and grant only the paths it needs.', line: svc.line }));
|
|
160
|
+
entry.environment = Object.entries(svc.env).map(([k, e]) => ({ name: k, line: e.line, valueRecorded: false }));
|
|
161
|
+
}
|
|
162
|
+
// bridges: a Nix service environment variable and the Haskell program that reads it
|
|
163
|
+
const hsFiles = Object.entries(files).filter(([p, t]) => /\.l?hs$/i.test(p) && typeof t === 'string');
|
|
164
|
+
const manifests = Object.entries(files).filter(([p, t]) => /\.cabal$/i.test(p) && typeof t === 'string').map(([path, text]) => ({ path, text }));
|
|
165
|
+
const executables = new Set();
|
|
166
|
+
if (manifests.length) { try { for (const pkg of (0,haskell_manifests/* analyzeHaskellManifests */.JS)(manifests).packages) for (const c of pkg.components || []) if (c.kind === 'executable') executables.add(c.name); } catch { /* no manifest evidence */ } }
|
|
167
|
+
const reads = [];
|
|
168
|
+
for (const [file, text] of hsFiles) {
|
|
169
|
+
const toks = (0,secrets/* tokenizeHaskell */.cH)(text);
|
|
170
|
+
for (let i = 0; i < toks.length; i++) {
|
|
171
|
+
const t = toks[i];
|
|
172
|
+
if (t.k !== 'i' || !/^(?:getEnv|lookupEnv)$/.test(t.v.replace(/^.*\./, ''))) continue;
|
|
173
|
+
let j = i + 1; while (toks[j] && toks[j].k === 'x' && toks[j].v === '(') j++;
|
|
174
|
+
if (toks[j] && toks[j].k === 's') { let line = 1; for (let q = 0; q < t.start; q++) if (text.charCodeAt(q) === 10) line++; reads.push({ file, line, variable: toks[j].v }); }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
for (const svc of services) {
|
|
178
|
+
const exec = svc.commands.map((c) => c.text).join(' ');
|
|
179
|
+
const launched = [...executables].filter((e) => new RegExp(`(?:^|[/\\s])${e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s|$)`).test(exec.replace(new RegExp(PH, 'g'), ' ')));
|
|
180
|
+
for (const [variable, e] of Object.entries(svc.env)) {
|
|
181
|
+
for (const r of reads.filter((x) => x.variable === variable)) {
|
|
182
|
+
bridges.push({ kind: 'env-read', variable, status: launched.length ? 'executable-linked' : 'candidate', executable: launched[0] || null,
|
|
183
|
+
nix: { service: svc.name, file: e.file, line: e.line, role: 'declares-environment' }, haskell: { file: r.file, line: r.line, role: 'reads-environment' },
|
|
184
|
+
provenance: { nix: 'systemd service environment (configuration)', haskell: 'getEnv/lookupEnv literal (source)', link: launched.length ? `the service launches ${launched[0]}, an executable built by the project's cabal file` : 'only the variable name matches: no evidence this service runs that program' },
|
|
185
|
+
flow: 'a runtime configuration bridge, not a data-flow proof' });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { version: NIX_AGENTS_VERSION, findings, agents, bridges };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// EXTERNAL MODULE: ./src/language/nixos-hardening.js
|
|
193
|
+
var nixos_hardening = __webpack_require__(188);
|
|
194
|
+
;// CONCATENATED MODULE: ./src/language/engine-pass.js
|
|
195
|
+
// Language supply-chain pass for the scan engine. Nix fetch/build/cache trust findings are
|
|
196
|
+
// supply-chain entries, so they join the ordinary `supplyChain` bucket (and from there SCA
|
|
197
|
+
// policy, scan health and every report) instead of a side channel. Static only: nothing here
|
|
198
|
+
// evaluates Nix, fetches a URL or runs a builder.
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
const isNixSource = (p) => /\.nix$/i.test(p);
|
|
210
|
+
const isFlakeLock = (p) => /(^|\/)flake\.lock$/i.test(p);
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* @param {Record<string,string>} files rel path -> content (sources and manifests as the engine read them)
|
|
214
|
+
* @returns {{supplyChain: object[], gaps: object[], analyzed: number}}
|
|
215
|
+
*/
|
|
216
|
+
const NIXOS_OPTION = /(?:^|[\s;{])(?:services|networking|security|systemd|users|environment|programs|boot|virtualisation|nix)\.[A-Za-z_]|(?:^|[\s;{])(?:services|networking|security|systemd|users|environment|programs|boot|virtualisation)\s*=\s*\{/m;
|
|
217
|
+
|
|
218
|
+
/** NixOS entry points of a Nix file set: every configuration.nix, else every module nothing imports (capped). */
|
|
219
|
+
function nixosEntries(nix, { max = 15 } = {}) {
|
|
220
|
+
const conf = Object.keys(nix).filter((p) => /(?:^|\/)configuration\.nix$/i.test(p)).sort().slice(0, 5);
|
|
221
|
+
const imported = new Set();
|
|
222
|
+
try { const { graph } = (0,discovery/* buildImportGraph */.oK)(nix); for (const deps of graph.values()) for (const d of deps) imported.add(d); } catch { /* no import graph */ }
|
|
223
|
+
const standalone = Object.keys(nix).filter((p) => !conf.includes(p) && !imported.has(p) && !/(?:^|\/)flake\.nix$/i.test(p) && NIXOS_OPTION.test(nix[p])).sort().slice(0, max);
|
|
224
|
+
return [...conf, ...standalone];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function _baseSupplyChain(files = {}, opts = {}) {
|
|
228
|
+
const nix = {};
|
|
229
|
+
for (const [p, text] of Object.entries(files)) {
|
|
230
|
+
if (typeof text !== 'string') continue;
|
|
231
|
+
if (!(isNixSource(p) || isFlakeLock(p))) continue;
|
|
232
|
+
if ((0,discovery/* isLanguageExcludedPath */.EG)(p)) continue;
|
|
233
|
+
nix[p] = text;
|
|
234
|
+
}
|
|
235
|
+
// Haskell: Hackage advisories and source/name policy (manifests, a freeze or a plan, and the .hs imports).
|
|
236
|
+
const hs = (0,haskell_supply/* analyzeHaskellSupply */.i1)(files, { root: opts.scanRoot || null, resolved: opts.resolved || null });
|
|
237
|
+
const sources = Object.keys(nix).filter(isNixSource);
|
|
238
|
+
if (!sources.length) {
|
|
239
|
+
if (!hs.components.length) return { supplyChain: [], gaps: [], analyzed: 0 };
|
|
240
|
+
return { supplyChain: hs.supplyChain, gaps: hs.gaps, analyzed: hs.components.length, haskell: hs };
|
|
241
|
+
}
|
|
242
|
+
// The effective configuration is decided from an ENTRY: configuration.nix when there is one, otherwise each NixOS module that
|
|
243
|
+
// nothing imports (a flake's host file under any name). With no entry at all only the file-local rules run.
|
|
244
|
+
const entries = nixosEntries(nix);
|
|
245
|
+
const reports = entries.length ? entries.map((entry) => (0,nix_build_trust/* analyzeNixBuildTrust */.OQ)({ files: nix, entry })) : [(0,nix_build_trust/* analyzeNixBuildTrust */.OQ)({ files: nix })];
|
|
246
|
+
const seenIds = new Set(); const merged = []; const gaps = [];
|
|
247
|
+
for (const r of reports) {
|
|
248
|
+
for (const f of r.findings) { const k = f.id || `${f.rule}|${f.file}|${f.line}`; if (!seenIds.has(k)) { seenIds.add(k); merged.push(f); } }
|
|
249
|
+
for (const g of r.gaps) gaps.push(g);
|
|
250
|
+
}
|
|
251
|
+
return { supplyChain: [...hs.supplyChain, ...merged], gaps: [...hs.gaps, ...gaps], analyzed: sources.length + hs.components.length, haskell: hs };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Findings (not supply-chain entries) from Nix: embedded-shell taint (NIX-003). Returns the findings plus the
|
|
256
|
+
* coverage gaps (unsupported shells, scripts built by expressions) so scan health can report them.
|
|
257
|
+
*/
|
|
258
|
+
function analyzeLanguageFindings(files = {}) {
|
|
259
|
+
const nix = {};
|
|
260
|
+
for (const [p, text] of Object.entries(files)) {
|
|
261
|
+
if (typeof text !== 'string' || !isNixSource(p) || (0,discovery/* isLanguageExcludedPath */.EG)(p)) continue;
|
|
262
|
+
nix[p] = text;
|
|
263
|
+
}
|
|
264
|
+
if (!Object.keys(nix).length) return { findings: [], gaps: [], analyzed: 0 };
|
|
265
|
+
const r = (0,nix_script_taint/* analyzeNixScripts */.S3)({ files: nix });
|
|
266
|
+
// secret placement sees referenced data files (sopsFile / agenix file) as well as the Nix sources
|
|
267
|
+
const sec = (0,nix_secrets/* analyzeNixSecrets */.D0)({ files: { ...files, ...nix } });
|
|
268
|
+
let agents = { findings: [] };
|
|
269
|
+
try { agents = analyzeNixAgents(files); } catch { /* agent/MCP rules are best effort */ }
|
|
270
|
+
// NIX-004: effective-configuration hardening, judged per NixOS entry point (configuration.nix). A finding with no
|
|
271
|
+
// option source (a catalog default decided it) is anchored to the entry file at line 1 and says so, so it can be
|
|
272
|
+
// reported, attributed and suppressed instead of being lineless. The files that decided the value travel with it.
|
|
273
|
+
const hardening = [];
|
|
274
|
+
const effective = [];
|
|
275
|
+
// Entry points: every configuration.nix, plus NixOS modules NOTHING imports (a standalone module, a flake's host file under any
|
|
276
|
+
// name): its options are the only place their values are decided, and a file name is not what makes a configuration real.
|
|
277
|
+
const confEntries = Object.keys(nix).filter((p) => /(?:^|\/)configuration\.nix$/i.test(p)).sort().slice(0, 5);
|
|
278
|
+
const entryList = nixosEntries(nix);
|
|
279
|
+
for (const entry of entryList) {
|
|
280
|
+
try {
|
|
281
|
+
const h = (0,nixos_hardening/* analyzeNixosHardening */.QC)({ entry, files: nix });
|
|
282
|
+
effective.push({ entry, completeness: h.reconciliation && h.reconciliation.completeness, unresolved: (h.reconciliation && h.reconciliation.unanalyzed) || [], truncated: [], target: h.target });
|
|
283
|
+
for (const f of h.findings) {
|
|
284
|
+
const ctl = [...new Set((f.evidence || []).flatMap((e) => (e.sources || []).map((x) => x.file)).filter(Boolean))];
|
|
285
|
+
const anchored = Number.isInteger(f.line) && f.file;
|
|
286
|
+
hardening.push({ ...f, file: anchored ? f.file : entry, line: anchored ? f.line : 1, locationKind: anchored ? 'option-source' : 'target-level', controlFiles: ctl, entry, entryKind: confEntries.includes(entry) ? 'configuration' : 'standalone-module', optionEvidence: f.evidence, option: f.evidence && f.evidence[0] ? f.evidence[0].option : null });
|
|
287
|
+
}
|
|
288
|
+
} catch (e) { r.gaps.push({ kind: 'hardening-failed', file: entry, reason: String((e && e.message) || e) }); }
|
|
289
|
+
}
|
|
290
|
+
// Two different rules of one family can land on the same line (a one-line configuration, or several defaults that
|
|
291
|
+
// anchor at the entry). The engine's per-(file, line, family) de-duplication must not merge them into one finding, so
|
|
292
|
+
// each carries a site key built from what makes it a different finding. The key also feeds the stable id.
|
|
293
|
+
const keyed = (list, parts) => list.map((f) => (f && f.siteKey ? f : { ...f, siteKey: parts(f).filter((x) => x !== null && x !== undefined && x !== '').join('|') }));
|
|
294
|
+
const out = [
|
|
295
|
+
...keyed(r.findings, (f) => [f.rule, f.attrPath, f.sink && f.sink.command, f.generatedLocation && `${f.generatedLocation.line}:${f.generatedLocation.startColumn}`]),
|
|
296
|
+
...keyed(sec.findings, (f) => [f.rule, f.source && f.source.detail, f.destination && f.destination.label]),
|
|
297
|
+
...agents.findings,
|
|
298
|
+
...keyed(hardening, (f) => [f.rule, f.subject]),
|
|
299
|
+
];
|
|
300
|
+
return { findings: out, gaps: [...r.gaps, ...sec.gaps], analyzed: r.analyzed, effectiveConfigs: effective };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The language supply chain: declared and resolved Hackage dependencies, Nix inputs and build trust, and (when the project exported
|
|
305
|
+
* one) the Nix closure matched against an advisory snapshot. The resolved data comes from EXPLICIT export files only.
|
|
306
|
+
*/
|
|
307
|
+
function analyzeLanguageSupplyChain(files = {}, opts = {}) {
|
|
308
|
+
let resolved = null; let closure = null;
|
|
309
|
+
try { resolved = (0,resolved_pass.resolvedHackageComponents)(files); } catch { resolved = null; }
|
|
310
|
+
try { closure = (0,resolved_pass.analyzeNixClosure)(files, { scanRoot: opts.scanRoot || null }); } catch (e) { closure = { findings: [], gaps: [{ kind: 'closure-analysis-failed', detail: String((e && e.message) || e).slice(0, 160) }], statuses: [], sources: [], status: 'failed' }; }
|
|
311
|
+
const base = _baseSupplyChain(files, { ...opts, resolved });
|
|
312
|
+
if (!closure) return resolved && resolved.summary ? { ...base, resolved: resolved.summary } : base;
|
|
313
|
+
return { ...base, supplyChain: [...base.supplyChain, ...closure.findings], gaps: [...(base.gaps || []), ...closure.gaps], analyzed: (base.analyzed || 0) + closure.statuses.length, resolved: resolved && resolved.summary, nixClosure: closure };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
/***/ })
|
|
318
|
+
|
|
319
|
+
};
|