@clear-capabilities/agentic-security-scanner 0.79.0 → 0.80.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/dist/178.index.js +1 -1
- package/dist/333.index.js +283 -0
- package/dist/384.index.js +1 -1
- package/dist/637.index.js +1 -1
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +90 -1
- package/dist/agentic-security.mjs +83 -83
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +6 -4
- package/src/.agentic-security/findings.json +104638 -0
- package/src/.agentic-security/last-scan.json +104638 -0
- package/src/.agentic-security/last-scan.json.sig +1 -0
- package/src/.agentic-security/scan-history.json +12562 -0
- package/src/.agentic-security/streak.json +21 -0
- package/src/dataflow/.agentic-security/findings.json +6086 -0
- package/src/dataflow/.agentic-security/last-scan.json +6086 -0
- package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
- package/src/dataflow/.agentic-security/scan-history.json +250 -0
- package/src/dataflow/.agentic-security/streak.json +21 -0
- package/src/dataflow/cross-service-taint.js +201 -0
- package/src/dataflow/formal-verify.js +204 -0
- package/src/dataflow/ifds-precise.js +222 -0
- package/src/dataflow/k2-summary-cache.js +153 -0
- package/src/dataflow/lib-taint-summaries.js +198 -0
- package/src/dataflow/privacy-taint.js +205 -0
- package/src/dataflow/smt-feasibility.js +189 -0
- package/src/engine.js +784 -127
- package/src/ir/.agentic-security/findings.json +4011 -0
- package/src/ir/.agentic-security/last-scan.json +4011 -0
- package/src/ir/.agentic-security/last-scan.json.sig +1 -0
- package/src/ir/.agentic-security/scan-history.json +193 -0
- package/src/ir/.agentic-security/streak.json +20 -0
- package/src/ir/cpp-preprocessor.js +142 -0
- package/src/ir/csharp-ir.js +604 -0
- package/src/ir/universal-ir.js +403 -0
- package/src/mcp/.agentic-security/findings.json +8632 -0
- package/src/mcp/.agentic-security/last-scan.json +8632 -0
- package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
- package/src/mcp/.agentic-security/scan-history.json +143 -0
- package/src/mcp/.agentic-security/streak.json +20 -0
- package/src/mcp/tools.js +90 -1
- package/src/posture/.agentic-security/findings.json +64004 -0
- package/src/posture/.agentic-security/last-scan.json +64004 -0
- package/src/posture/.agentic-security/last-scan.json.sig +1 -0
- package/src/posture/.agentic-security/scan-history.json +7162 -0
- package/src/posture/.agentic-security/streak.json +21 -0
- package/src/posture/api-contract.js +193 -0
- package/src/posture/attack-taxonomy.js +227 -0
- package/src/posture/compliance-policy.js +218 -0
- package/src/posture/composite-risk.js +122 -0
- package/src/posture/csharp-analysis.js +330 -0
- package/src/posture/exploit-bundle.js +210 -0
- package/src/posture/federated-learning.js +172 -0
- package/src/posture/license-attributions.js +94 -0
- package/src/posture/license-graph.js +238 -0
- package/src/posture/pqc-migration-plan.js +158 -0
- package/src/posture/reachability-filter.js +33 -2
- package/src/posture/realtime-cve-monitor.js +214 -0
- package/src/posture/runtime-correlation.js +174 -0
- package/src/posture/sbom-diff.js +171 -0
- package/src/posture/sca-policy.js +235 -0
- package/src/posture/sca-upgrade.js +259 -0
- package/src/posture/threat-model-auto.js +268 -0
- package/src/posture/triage-learning.js +170 -0
- package/src/posture/triage.js +26 -1
- package/src/sast/.agentic-security/findings.json +6154 -0
- package/src/sast/.agentic-security/last-scan.json +6154 -0
- package/src/sast/.agentic-security/last-scan.json.sig +1 -0
- package/src/sast/.agentic-security/scan-history.json +941 -0
- package/src/sast/.agentic-security/streak.json +22 -0
- package/src/sast/_secret-entropy.js +145 -0
- package/src/sast/cloud-iam.js +312 -0
- package/src/sast/cpp.js +138 -4
- package/src/sast/crypto-protocol.js +388 -0
- package/src/sast/csharp-tokenizer.js +392 -0
- package/src/sast/csharp.js +924 -138
- package/src/sast/dapp-frontend.js +200 -0
- package/src/sast/k8s-admission.js +271 -0
- package/src/sast/llm-app.js +272 -0
- package/src/sast/ml-supply-chain.js +259 -0
- package/src/sast/mobile.js +224 -0
- package/src/sast/post-quantum-crypto.js +348 -0
- package/src/sast/web3-advanced.js +375 -0
- package/src/sca/.agentic-security/findings.json +7460 -0
- package/src/sca/.agentic-security/last-scan.json +7460 -0
- package/src/sca/.agentic-security/last-scan.json.sig +1 -0
- package/src/sca/.agentic-security/scan-history.json +113 -0
- package/src/sca/.agentic-security/streak.json +21 -0
- package/src/sca/CLAUDE.md +161 -0
- package/src/sca/binary-metadata.js +37 -15
- package/src/sca/sigstore-verify.js +215 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// DApp frontend Web3 risks — Item #3 (companion to web3-advanced.js).
|
|
2
|
+
//
|
|
3
|
+
// Wallet-interacting frontend (ethers.js, viem, wagmi, web3.js, RainbowKit,
|
|
4
|
+
// Privy) has its own attack surface that doesn't live in the Solidity
|
|
5
|
+
// detector:
|
|
6
|
+
//
|
|
7
|
+
// 1. UNLIMITED_APPROVAL — token.approve(spender, MaxUint256) /
|
|
8
|
+
// ethers.constants.MaxUint256 / 2**256-1
|
|
9
|
+
// without the user being able to scope it
|
|
10
|
+
// 2. ETH_SIGN_USED — provider.request({method:'eth_sign'}) — the
|
|
11
|
+
// "death sign" — accepts arbitrary opaque hash
|
|
12
|
+
// 3. PERSONAL_SIGN_NO_DOMAIN — personal_sign without the EIP-191
|
|
13
|
+
// personal-message prefix being shown to user
|
|
14
|
+
// 4. WINDOW_ETHEREUM_UNGUARDED — directly using window.ethereum without
|
|
15
|
+
// trusted-RPC check (lets a malicious
|
|
16
|
+
// wallet extension inject)
|
|
17
|
+
// 5. WALLETCONNECT_BRIDGE_INSECURE — using wc.bridge with http:// or a
|
|
18
|
+
// third-party bridge URL
|
|
19
|
+
// 6. PRIVATE_KEY_IN_FRONTEND — Wallet.fromMnemonic / new Wallet(privKey)
|
|
20
|
+
// in client-side code (must only be in
|
|
21
|
+
// server/SDK; never browser)
|
|
22
|
+
// 7. SIGN_TYPED_DATA_NO_DOMAIN — eth_signTypedData with empty/missing
|
|
23
|
+
// domain.chainId
|
|
24
|
+
// 8. ETHERSCAN_API_KEY_INLINE — Etherscan / Alchemy / Infura API key
|
|
25
|
+
// hard-coded in client bundle
|
|
26
|
+
//
|
|
27
|
+
// File scope: JS / TS / JSX / TSX. Returns empty on .sol / .vy.
|
|
28
|
+
|
|
29
|
+
import { blankComments } from './_comment-strip.js';
|
|
30
|
+
|
|
31
|
+
function _line(raw, idx) { return raw.slice(0, idx).split('\n').length; }
|
|
32
|
+
function _snip(raw, line) { return (raw.split('\n')[line - 1] || '').trim().slice(0, 200); }
|
|
33
|
+
|
|
34
|
+
function _shape(file, line, ruleId, vuln, fam, sev, cwe, remediation, description) {
|
|
35
|
+
return {
|
|
36
|
+
id: `${ruleId}:${file}:${line}`,
|
|
37
|
+
file, line, vuln, severity: sev, cwe,
|
|
38
|
+
family: fam, parser: 'DAPP-FRONTEND',
|
|
39
|
+
confidence: 0.8,
|
|
40
|
+
description: description || vuln,
|
|
41
|
+
remediation,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const _RELEVANT_FILE = /\.(?:[jt]sx?|mjs|cjs)$/i;
|
|
46
|
+
|
|
47
|
+
function _isWeb3Frontend(text) {
|
|
48
|
+
return /\bethers\b|\bviem\b|\bwagmi\b|\bweb3\b|\bRainbow\b|\bPrivy\b|\bWalletConnect\b|window\.ethereum/.test(text);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function detectUnlimitedApproval(file, raw, code, out, seen) {
|
|
52
|
+
// ERC-20 approve calls with MaxUint256 / 2**256-1 / ethers.constants.MaxUint256.
|
|
53
|
+
const patterns = [
|
|
54
|
+
/\bapprove\s*\([^)]*?(?:MaxUint256|MAX_UINT256|2\s*\*\*\s*256\s*-\s*1|ethers\.constants\.MaxUint256|maxUint256|2n\s*\*\*\s*256n\s*-\s*1n)\b[^)]*\)/g,
|
|
55
|
+
/\bapprove\s*\([^)]*?["']0x[fF]{64}["']\b[^)]*\)/g,
|
|
56
|
+
];
|
|
57
|
+
for (const re of patterns) {
|
|
58
|
+
let m;
|
|
59
|
+
while ((m = re.exec(code))) {
|
|
60
|
+
const ln = _line(raw, m.index);
|
|
61
|
+
const id = `dapp-unlimited-approval:${file}:${ln}`;
|
|
62
|
+
if (seen.has(id)) continue;
|
|
63
|
+
seen.add(id);
|
|
64
|
+
out.push(_shape(file, ln, 'dapp-unlimited-approval',
|
|
65
|
+
'ERC-20 approve with MaxUint256 — unlimited spender allowance',
|
|
66
|
+
'unlimited-approval', 'high', 'CWE-863',
|
|
67
|
+
'Approve only the exact amount needed for the current operation. Unlimited approvals are the dominant attack vector for drainer scams — once approved, a compromised spender contract can move the entire token balance forever. Use Permit2 (Uniswap) for short-lived approvals OR EIP-2612 permit() for single-shot allowances.',
|
|
68
|
+
'Unlimited approve is the #1 cause of wallet-drainer losses in 2024-2025 (>$300M annually). Etherscan now warns users about MaxUint256 approvals at signing time.'));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function detectEthSign(file, raw, code, out, seen) {
|
|
74
|
+
// Pattern: provider.request({method: 'eth_sign'}) — the "death sign".
|
|
75
|
+
const re = /\b(?:provider|signer|wallet|window\.ethereum)\s*\.\s*request\s*\(\s*\{\s*method\s*:\s*['"]eth_sign['"]/g;
|
|
76
|
+
let m;
|
|
77
|
+
while ((m = re.exec(code))) {
|
|
78
|
+
const ln = _line(raw, m.index);
|
|
79
|
+
const id = `dapp-eth-sign:${file}:${ln}`;
|
|
80
|
+
if (seen.has(id)) continue;
|
|
81
|
+
seen.add(id);
|
|
82
|
+
out.push(_shape(file, ln, 'dapp-eth-sign',
|
|
83
|
+
'eth_sign — signs arbitrary 32-byte hash with no prefix (the "death sign")',
|
|
84
|
+
'eth-sign-used', 'critical', 'CWE-345',
|
|
85
|
+
'Never use eth_sign. The 32-byte hash being signed can be a valid transaction hash, valid permit message, or anything else — there is no domain separation. Use personal_sign for human messages or eth_signTypedData_v4 for structured data. MetaMask now warns when a site requests eth_sign.',
|
|
86
|
+
'eth_sign signs without ANY prefix. An attacker presents a hash that happens to be the keccak of a transaction; the user signs; the signed transaction is now broadcastable. This is the underlying bug in many phishing drainers.'));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function detectPersonalSignNoMessage(file, raw, code, out, seen) {
|
|
91
|
+
// personal_sign with an empty / non-descriptive message string.
|
|
92
|
+
const re = /\bpersonal_sign\b|\bsignMessage\s*\(\s*['"][^'"]{0,20}['"]/g;
|
|
93
|
+
let m;
|
|
94
|
+
while ((m = re.exec(code))) {
|
|
95
|
+
// Detect: signMessage("") or signMessage("ok") — too short to be meaningful.
|
|
96
|
+
if (m[0].startsWith('signMessage')) {
|
|
97
|
+
const ln = _line(raw, m.index);
|
|
98
|
+
const id = `dapp-personal-sign-empty:${file}:${ln}`;
|
|
99
|
+
if (seen.has(id)) continue;
|
|
100
|
+
seen.add(id);
|
|
101
|
+
out.push(_shape(file, ln, 'dapp-personal-sign-empty',
|
|
102
|
+
'signMessage with empty or trivial message — user has no context to evaluate',
|
|
103
|
+
'personal-sign-no-domain', 'medium', 'CWE-345',
|
|
104
|
+
'Include the action being authorized, the domain (your dapp URL), the address being affected, and a unique nonce in the message. The signing UI displays the message to the user — make it meaningful so the user can detect phishing.',
|
|
105
|
+
'A short / blank message means the user can\'t tell if they\'re signing in to YOUR app or a malicious clone. EIP-4361 (Sign-In With Ethereum) encodes a structured format that wallets can verify.'));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function detectWalletPrivKeyInFrontend(file, raw, code, out, seen) {
|
|
111
|
+
// new Wallet(0x...) / Wallet.fromMnemonic in code that's reachable from a
|
|
112
|
+
// bundle (presence of React, Next, Vite, Vue is signal).
|
|
113
|
+
if (!/\b(?:React|next\/|vite|Vue|svelte|expo|electron)\b/.test(raw)) return;
|
|
114
|
+
const patterns = [
|
|
115
|
+
/\bnew\s+(?:ethers\.)?Wallet\s*\(\s*['"]0x[a-fA-F0-9]{64}['"]/g,
|
|
116
|
+
/\bWallet\.fromMnemonic\s*\(/g,
|
|
117
|
+
/\bWallet\.fromPrivateKey\s*\(/g,
|
|
118
|
+
];
|
|
119
|
+
for (const re of patterns) {
|
|
120
|
+
let m;
|
|
121
|
+
while ((m = re.exec(code))) {
|
|
122
|
+
const ln = _line(raw, m.index);
|
|
123
|
+
const id = `dapp-wallet-privkey-frontend:${file}:${ln}`;
|
|
124
|
+
if (seen.has(id)) continue;
|
|
125
|
+
seen.add(id);
|
|
126
|
+
out.push(_shape(file, ln, 'dapp-wallet-privkey-frontend',
|
|
127
|
+
'Wallet constructed from raw key / mnemonic in client-side code',
|
|
128
|
+
'private-key-in-frontend', 'critical', 'CWE-798',
|
|
129
|
+
'Never construct a Wallet with a raw private key or mnemonic in code that ships to the browser. Use a wallet provider (window.ethereum, WalletConnect, Privy) and request signatures via the standard JSON-RPC methods.',
|
|
130
|
+
'A private key in client-side code is visible in the bundle. Even worse, mnemonic-based flows often persist seed phrases to localStorage — accessible to any extension or XSS payload.'));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function detectSignTypedDataNoChainId(file, raw, code, out, seen) {
|
|
136
|
+
// eth_signTypedData with domain.chainId missing or hard-coded to 1
|
|
137
|
+
// (won't validate on L2s, allows cross-chain replay).
|
|
138
|
+
const re = /signTypedData(?:_v[34])?\s*\(\s*\{[\s\S]{0,800}?domain\s*:\s*\{([^}]*)\}/g;
|
|
139
|
+
let m;
|
|
140
|
+
while ((m = re.exec(code))) {
|
|
141
|
+
const domain = m[1];
|
|
142
|
+
if (/\bchainId\b/.test(domain)) continue;
|
|
143
|
+
const ln = _line(raw, m.index);
|
|
144
|
+
const id = `dapp-typed-data-no-chainid:${file}:${ln}`;
|
|
145
|
+
if (seen.has(id)) continue;
|
|
146
|
+
seen.add(id);
|
|
147
|
+
out.push(_shape(file, ln, 'dapp-typed-data-no-chainid',
|
|
148
|
+
'eth_signTypedData without domain.chainId — cross-chain replay surface',
|
|
149
|
+
'typed-data-no-chainid', 'high', 'CWE-294',
|
|
150
|
+
'Always include `chainId` in the EIP-712 domain. Without it, a signature valid on Ethereum mainnet is also valid on every L2 and testnet — letting an attacker reuse a meta-tx signature across chains to drain the same user.',
|
|
151
|
+
'EIP-712 domain separator binds the signature to (name, version, chainId, verifyingContract). Drop any field and signatures become portable to environments where the contract has different semantics.'));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function detectEtherscanApiKeyInline(file, raw, code, out, seen) {
|
|
156
|
+
// Etherscan / Alchemy / Infura API key embedded inline.
|
|
157
|
+
const patterns = [
|
|
158
|
+
/\b(?:etherscan|polygonscan|bscscan|arbiscan)\.io\/api[^"'`]*apikey=([A-Z0-9]{30,40})/gi,
|
|
159
|
+
/\balchemy\.com\/v2\/([A-Za-z0-9_-]{20,40})/g,
|
|
160
|
+
/\binfura\.io\/v3\/([A-Za-z0-9]{30,40})/g,
|
|
161
|
+
];
|
|
162
|
+
for (const re of patterns) {
|
|
163
|
+
let m;
|
|
164
|
+
while ((m = re.exec(code))) {
|
|
165
|
+
const ln = _line(raw, m.index);
|
|
166
|
+
const id = `dapp-rpc-key-inline:${file}:${ln}`;
|
|
167
|
+
if (seen.has(id)) continue;
|
|
168
|
+
seen.add(id);
|
|
169
|
+
out.push(_shape(file, ln, 'dapp-rpc-key-inline',
|
|
170
|
+
'RPC provider key embedded in client-side code (visible in bundle)',
|
|
171
|
+
'rpc-key-inline', 'high', 'CWE-798',
|
|
172
|
+
'Move RPC provider API keys to a server-side proxy (e.g. Next.js API route, Cloudflare Worker) that adds the key before forwarding. Embedded keys are scraped by bots within hours of deploy and used to grief your quota.',
|
|
173
|
+
'Alchemy / Infura / Etherscan keys in browser bundles are scraped by automated tools. Beyond quota exhaustion, leaked keys can be used to deanonymize your users\' RPC traffic.'));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function scanDappFrontend(fp, raw) {
|
|
179
|
+
if (process.env.AGENTIC_SECURITY_NO_DAPP === '1') return [];
|
|
180
|
+
if (!raw || raw.length > 500_000) return [];
|
|
181
|
+
if (!_RELEVANT_FILE.test(fp)) return [];
|
|
182
|
+
if (!_isWeb3Frontend(raw)) return [];
|
|
183
|
+
const code = blankComments(raw);
|
|
184
|
+
const out = [];
|
|
185
|
+
const seen = new Set();
|
|
186
|
+
try { detectUnlimitedApproval(fp, raw, code, out, seen); } catch {}
|
|
187
|
+
try { detectEthSign(fp, raw, code, out, seen); } catch {}
|
|
188
|
+
try { detectPersonalSignNoMessage(fp, raw, code, out, seen); } catch {}
|
|
189
|
+
try { detectWalletPrivKeyInFrontend(fp, raw, code, out, seen); } catch {}
|
|
190
|
+
try { detectSignTypedDataNoChainId(fp, raw, code, out, seen); } catch {}
|
|
191
|
+
try { detectEtherscanApiKeyInline(fp, raw, code, out, seen); } catch {}
|
|
192
|
+
for (const f of out) f.file = fp;
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export const _internals = {
|
|
197
|
+
_isWeb3Frontend, detectUnlimitedApproval, detectEthSign,
|
|
198
|
+
detectPersonalSignNoMessage, detectWalletPrivKeyInFrontend,
|
|
199
|
+
detectSignTypedDataNoChainId, detectEtherscanApiKeyInline,
|
|
200
|
+
};
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Kubernetes admission / RBAC / PodSecurity analyzer — Item #4 of the
|
|
2
|
+
// world-class+3 plan.
|
|
3
|
+
//
|
|
4
|
+
// Coverage:
|
|
5
|
+
//
|
|
6
|
+
// RBAC (Role / ClusterRole / RoleBinding / ClusterRoleBinding):
|
|
7
|
+
// - k8s-rbac-wildcard-verbs verbs: ['*']
|
|
8
|
+
// - k8s-rbac-wildcard-resources resources: ['*']
|
|
9
|
+
// - k8s-rbac-wildcard-apigroups apiGroups: ['*']
|
|
10
|
+
// - k8s-rbac-cluster-admin ClusterRoleBinding → cluster-admin
|
|
11
|
+
// - k8s-rbac-system-anonymous binding subject system:anonymous /
|
|
12
|
+
// system:unauthenticated
|
|
13
|
+
// - k8s-rbac-system-authenticated-write bound to write-capable role
|
|
14
|
+
//
|
|
15
|
+
// PodSecurity:
|
|
16
|
+
// - k8s-pod-privileged securityContext.privileged: true
|
|
17
|
+
// - k8s-pod-hostnetwork hostNetwork: true
|
|
18
|
+
// - k8s-pod-hostpid hostPID: true
|
|
19
|
+
// - k8s-pod-hostipc hostIPC: true
|
|
20
|
+
// - k8s-pod-hostpath volumes[].hostPath
|
|
21
|
+
// - k8s-pod-allow-privesc allowPrivilegeEscalation: true
|
|
22
|
+
// - k8s-pod-run-as-root runAsNonRoot: false OR runAsUser: 0
|
|
23
|
+
// - k8s-pod-capabilities-broad capabilities.add: SYS_ADMIN / NET_ADMIN / ALL
|
|
24
|
+
//
|
|
25
|
+
// Admission webhooks:
|
|
26
|
+
// - k8s-webhook-failure-ignore ValidatingWebhookConfiguration with
|
|
27
|
+
// failurePolicy: Ignore on security-critical webhook
|
|
28
|
+
//
|
|
29
|
+
// Service account / token mount:
|
|
30
|
+
// - k8s-sa-automount-admin ServiceAccount with automountServiceAccountToken !== false
|
|
31
|
+
// AND bound to a powerful ClusterRole
|
|
32
|
+
//
|
|
33
|
+
// Detection: lightweight YAML parsing via regex on `kind:` and `key: value`
|
|
34
|
+
// pairs. We avoid pulling in a YAML lib for now — k8s YAML is simple enough
|
|
35
|
+
// that pattern detection on key prefixes is reliable.
|
|
36
|
+
//
|
|
37
|
+
// Opt-out: AGENTIC_SECURITY_NO_K8S_ADM=1
|
|
38
|
+
|
|
39
|
+
import { blankComments } from './_comment-strip.js';
|
|
40
|
+
|
|
41
|
+
const _IS_K8S_FILE = /\.(?:yaml|yml)$/i;
|
|
42
|
+
|
|
43
|
+
function _line(raw, idx) { return raw.slice(0, idx).split('\n').length; }
|
|
44
|
+
function _snip(raw, line) { return (raw.split('\n')[line - 1] || '').trim().slice(0, 200); }
|
|
45
|
+
|
|
46
|
+
function _shape(file, line, ruleId, vuln, fam, sev, cwe, remediation, description) {
|
|
47
|
+
return {
|
|
48
|
+
id: `${ruleId}:${file}:${line}`,
|
|
49
|
+
file, line, vuln, severity: sev, cwe,
|
|
50
|
+
family: fam, parser: 'K8S-ADM',
|
|
51
|
+
confidence: 0.85,
|
|
52
|
+
stride: 'Elevation of Privilege',
|
|
53
|
+
description: description || vuln,
|
|
54
|
+
remediation,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function _docKinds(raw) {
|
|
59
|
+
// Heuristic: split on `---` document separators and extract kind: line.
|
|
60
|
+
return raw.split(/^---\s*$/m).map(doc => {
|
|
61
|
+
const k = /kind:\s*([A-Za-z0-9]+)/.exec(doc);
|
|
62
|
+
return { kind: k ? k[1] : null, body: doc };
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── RBAC ───────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function detectRbac(file, raw, out, seen) {
|
|
69
|
+
const docs = _docKinds(raw);
|
|
70
|
+
for (const { kind, body } of docs) {
|
|
71
|
+
if (!kind) continue;
|
|
72
|
+
if (!/^(?:Role|ClusterRole|RoleBinding|ClusterRoleBinding)$/.test(kind)) continue;
|
|
73
|
+
|
|
74
|
+
// Role / ClusterRole rules — wildcards.
|
|
75
|
+
if (/^(?:Role|ClusterRole)$/.test(kind)) {
|
|
76
|
+
// Use the entire document body as the rules block — simpler than trying
|
|
77
|
+
// to delimit the rules: section across all YAML formatting variants.
|
|
78
|
+
const block = body;
|
|
79
|
+
if (/verbs:\s*\[\s*['"]?\*['"]?\s*\]/.test(block) || /verbs:\s*\n\s*-\s*['"]?\*['"]?/.test(block)) {
|
|
80
|
+
const ln = _line(raw, (block.match(/verbs:/) || [''])[0] || '');
|
|
81
|
+
const id = `k8s-rbac-wildcard-verbs:${file}:${ln}`;
|
|
82
|
+
if (!seen.has(id)) {
|
|
83
|
+
seen.add(id);
|
|
84
|
+
out.push(_shape(file, ln, 'k8s-rbac-wildcard-verbs',
|
|
85
|
+
`${kind} grants wildcard verbs ['*'] — full action access`,
|
|
86
|
+
'k8s-rbac-wildcard', 'high', 'CWE-269',
|
|
87
|
+
'Replace `verbs: ["*"]` with the specific verbs needed (get, list, watch, create, update, patch, delete). Use kubectl auth can-i to confirm minimum required set.',
|
|
88
|
+
'Wildcard verbs include exec, port-forward, and other powerful operations beyond CRUD. CIS Kubernetes Benchmark 5.1.x explicitly bans wildcard verbs in production RBAC.'));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (/resources:\s*\[\s*['"]?\*['"]?\s*\]/.test(block) || /resources:\s*\n\s*-\s*['"]?\*['"]?/.test(block)) {
|
|
92
|
+
const ln = _line(raw, block.match(/resources:/)[0]);
|
|
93
|
+
const id = `k8s-rbac-wildcard-resources:${file}:${ln}`;
|
|
94
|
+
if (!seen.has(id)) {
|
|
95
|
+
seen.add(id);
|
|
96
|
+
out.push(_shape(file, ln, 'k8s-rbac-wildcard-resources',
|
|
97
|
+
`${kind} grants wildcard resources ['*'] — every Kubernetes object type`,
|
|
98
|
+
'k8s-rbac-wildcard', 'high', 'CWE-269',
|
|
99
|
+
'Enumerate the specific resource kinds (pods, services, deployments, secrets, configmaps, ...) the role actually needs. Wildcard resources includes secrets, which exposes credential material to every subject.',
|
|
100
|
+
'Wildcard resources include secrets, certificates, and CRDs. The 2019-2021 cryptojacking K8s incidents all leveraged over-broad ClusterRoles with wildcard resources.'));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (/apiGroups:\s*\[\s*['"]?\*['"]?\s*\]/.test(block) || /apiGroups:\s*\n\s*-\s*['"]?\*['"]?/.test(block)) {
|
|
104
|
+
const ln = _line(raw, block.match(/apiGroups:/)[0]);
|
|
105
|
+
const id = `k8s-rbac-wildcard-apigroups:${file}:${ln}`;
|
|
106
|
+
if (!seen.has(id)) {
|
|
107
|
+
seen.add(id);
|
|
108
|
+
out.push(_shape(file, ln, 'k8s-rbac-wildcard-apigroups',
|
|
109
|
+
`${kind} grants wildcard apiGroups — every API group including CRDs`,
|
|
110
|
+
'k8s-rbac-wildcard', 'medium', 'CWE-269',
|
|
111
|
+
'List the specific apiGroups needed (e.g. "", "apps", "batch", "rbac.authorization.k8s.io"). Wildcard apiGroups gives access to every CRD that may be installed later — future-proofing in the wrong direction.',
|
|
112
|
+
'Wildcard apiGroups is especially dangerous when CRDs grant cluster-level permissions through their own controllers (cert-manager, ArgoCD, Tekton).'));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// RoleBinding / ClusterRoleBinding
|
|
118
|
+
if (/^(?:RoleBinding|ClusterRoleBinding)$/.test(kind)) {
|
|
119
|
+
// cluster-admin binding.
|
|
120
|
+
if (/roleRef:\s*[\s\S]*?name:\s*cluster-admin/.test(body)) {
|
|
121
|
+
const ln = _line(raw, 'cluster-admin');
|
|
122
|
+
const id = `k8s-rbac-cluster-admin:${file}:${ln}`;
|
|
123
|
+
if (!seen.has(id)) {
|
|
124
|
+
seen.add(id);
|
|
125
|
+
out.push(_shape(file, ln, 'k8s-rbac-cluster-admin',
|
|
126
|
+
`${kind} binds to cluster-admin — full cluster control`,
|
|
127
|
+
'k8s-rbac-cluster-admin', 'critical', 'CWE-269',
|
|
128
|
+
'Replace cluster-admin with a narrowly-scoped ClusterRole or RoleBinding limited to the operational namespace. Use Role/RoleBinding (namespaced) instead of ClusterRoleBinding wherever possible.',
|
|
129
|
+
'cluster-admin is the K8s equivalent of root. A bound user/SA can install controllers, create privileged pods, and exfiltrate every secret. CIS K8s 5.1.1 mandates minimizing cluster-admin bindings.'));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// system:anonymous / system:unauthenticated subjects.
|
|
133
|
+
if (/subjects:\s*[\s\S]*?(?:name:\s*system:(?:anonymous|unauthenticated))/.test(body)) {
|
|
134
|
+
const ln = _line(raw, 'system:');
|
|
135
|
+
const id = `k8s-rbac-system-anonymous:${file}:${ln}`;
|
|
136
|
+
if (!seen.has(id)) {
|
|
137
|
+
seen.add(id);
|
|
138
|
+
out.push(_shape(file, ln, 'k8s-rbac-system-anonymous',
|
|
139
|
+
`${kind} binds a role to system:anonymous / system:unauthenticated`,
|
|
140
|
+
'k8s-rbac-anonymous', 'critical', 'CWE-862',
|
|
141
|
+
'Remove the anonymous/unauthenticated subject. Replace with a ServiceAccount or explicit User/Group bound only after authentication. If you genuinely need a permissionless API for a probe, scope it to /healthz / /readyz only.',
|
|
142
|
+
'Binding any role to system:anonymous makes that role available without authentication — anyone with network reach to the API server can act with those permissions.'));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// system:authenticated bound to write-capable role.
|
|
146
|
+
if (/subjects:\s*[\s\S]*?name:\s*system:authenticated/.test(body) &&
|
|
147
|
+
/roleRef:\s*[\s\S]*?name:\s*(?:cluster-admin|admin|edit|system:node)/.test(body)) {
|
|
148
|
+
const ln = _line(raw, 'system:authenticated');
|
|
149
|
+
const id = `k8s-rbac-system-authenticated-write:${file}:${ln}`;
|
|
150
|
+
if (!seen.has(id)) {
|
|
151
|
+
seen.add(id);
|
|
152
|
+
out.push(_shape(file, ln, 'k8s-rbac-system-authenticated-write',
|
|
153
|
+
`${kind} binds a write-capable role to system:authenticated`,
|
|
154
|
+
'k8s-rbac-overbroad-binding', 'high', 'CWE-863',
|
|
155
|
+
'Replace system:authenticated with specific groups, ServiceAccounts, or users. system:authenticated includes every Service Account in every namespace + every legitimately-issued client cert.',
|
|
156
|
+
'system:authenticated is essentially "anyone who has a valid token" — including every default service account on every workload, friendly or hostile.'));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── PodSecurity ────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
function detectPodSecurity(file, raw, out, seen) {
|
|
166
|
+
const docs = _docKinds(raw);
|
|
167
|
+
for (const { kind, body } of docs) {
|
|
168
|
+
if (!kind) continue;
|
|
169
|
+
if (!/^(?:Pod|Deployment|StatefulSet|DaemonSet|Job|CronJob|ReplicaSet|ReplicationController)$/.test(kind)) continue;
|
|
170
|
+
|
|
171
|
+
const checks = [
|
|
172
|
+
{ re: /privileged:\s*true\b/g, rule: 'k8s-pod-privileged', sev: 'critical', cwe: 'CWE-250',
|
|
173
|
+
vuln: 'Pod runs in privileged mode',
|
|
174
|
+
remediation: 'Remove privileged: true. Use capability drops (capabilities.drop: [ALL]) and capabilities.add only the specific Linux caps required.' },
|
|
175
|
+
{ re: /hostNetwork:\s*true\b/g, rule: 'k8s-pod-hostnetwork', sev: 'high', cwe: 'CWE-668',
|
|
176
|
+
vuln: 'Pod uses host network — shares node IP stack',
|
|
177
|
+
remediation: 'Remove hostNetwork: true. If you need NodePort behavior, use a Service of type NodePort or LoadBalancer instead.' },
|
|
178
|
+
{ re: /hostPID:\s*true\b/g, rule: 'k8s-pod-hostpid', sev: 'high', cwe: 'CWE-668',
|
|
179
|
+
vuln: 'Pod uses host PID namespace — can see/signal node processes',
|
|
180
|
+
remediation: 'Remove hostPID: true. Container escape research targets host-pid pods first.' },
|
|
181
|
+
{ re: /hostIPC:\s*true\b/g, rule: 'k8s-pod-hostipc', sev: 'high', cwe: 'CWE-668',
|
|
182
|
+
vuln: 'Pod uses host IPC namespace',
|
|
183
|
+
remediation: 'Remove hostIPC: true. Almost no workload genuinely needs this.' },
|
|
184
|
+
{ re: /hostPath:\s*\n\s*path:/g, rule: 'k8s-pod-hostpath', sev: 'high', cwe: 'CWE-732',
|
|
185
|
+
vuln: 'Pod mounts a hostPath volume — node-filesystem reach',
|
|
186
|
+
remediation: 'Replace hostPath with PersistentVolumeClaim or ConfigMap. hostPath mounts let the pod read any file the node user can read — including /var/lib/kubelet/pods/* (other pods\' secrets).' },
|
|
187
|
+
{ re: /allowPrivilegeEscalation:\s*true\b/g, rule: 'k8s-pod-allow-privesc', sev: 'high', cwe: 'CWE-250',
|
|
188
|
+
vuln: 'Pod allows privilege escalation (no_new_privs disabled)',
|
|
189
|
+
remediation: 'Set allowPrivilegeEscalation: false. This sets the no_new_privs bit, preventing setuid / setgid binaries from gaining additional privileges.' },
|
|
190
|
+
{ re: /runAsNonRoot:\s*false\b/g, rule: 'k8s-pod-run-as-root', sev: 'medium', cwe: 'CWE-250',
|
|
191
|
+
vuln: 'Pod explicitly allows running as root',
|
|
192
|
+
remediation: 'Set runAsNonRoot: true and runAsUser: <non-zero>. Build images with a non-root USER directive.' },
|
|
193
|
+
{ re: /runAsUser:\s*0\b/g, rule: 'k8s-pod-run-as-root', sev: 'medium', cwe: 'CWE-250',
|
|
194
|
+
vuln: 'Pod runAsUser: 0 (root)',
|
|
195
|
+
remediation: 'Set runAsUser to a non-zero UID (e.g. 1000). Combine with runAsNonRoot: true for defense-in-depth.' },
|
|
196
|
+
{ re: /capabilities:\s*\n\s*add:\s*[\s\S]{0,200}?(?:SYS_ADMIN|NET_ADMIN|SYS_PTRACE|ALL)\b/g,
|
|
197
|
+
rule: 'k8s-pod-capabilities-broad', sev: 'high', cwe: 'CWE-250',
|
|
198
|
+
vuln: 'Pod adds dangerous Linux capability (SYS_ADMIN / NET_ADMIN / SYS_PTRACE / ALL)',
|
|
199
|
+
remediation: 'Drop ALL and add only the minimum cap(s) needed. SYS_ADMIN approximates root inside the container; NET_ADMIN allows iptables manipulation; SYS_PTRACE allows reading every process\'s memory.' },
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
for (const c of checks) {
|
|
203
|
+
let m;
|
|
204
|
+
while ((m = c.re.exec(body))) {
|
|
205
|
+
const docStart = raw.indexOf(body);
|
|
206
|
+
const ln = _line(raw, body.slice(0, m.index)) + (docStart > 0 ? _line(raw, raw.slice(0, docStart)) - 1 : 0);
|
|
207
|
+
const id = `${c.rule}:${file}:${ln}`;
|
|
208
|
+
if (seen.has(id)) continue;
|
|
209
|
+
seen.add(id);
|
|
210
|
+
out.push(_shape(file, ln, c.rule, c.vuln,
|
|
211
|
+
c.rule.replace(/^k8s-pod-/, 'k8s-pod-security-'), c.sev, c.cwe,
|
|
212
|
+
c.remediation,
|
|
213
|
+
undefined));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Admission webhooks ─────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
function detectWebhooks(file, raw, out, seen) {
|
|
222
|
+
const docs = _docKinds(raw);
|
|
223
|
+
for (const { kind, body } of docs) {
|
|
224
|
+
if (!/^(?:ValidatingWebhookConfiguration|MutatingWebhookConfiguration)$/.test(kind || '')) continue;
|
|
225
|
+
// failurePolicy: Ignore on security-sensitive admission webhook.
|
|
226
|
+
if (/failurePolicy:\s*Ignore\b/.test(body)) {
|
|
227
|
+
const ln = _line(raw, body.match(/failurePolicy:/)[0]);
|
|
228
|
+
const id = `k8s-webhook-failure-ignore:${file}:${ln}`;
|
|
229
|
+
if (!seen.has(id)) {
|
|
230
|
+
seen.add(id);
|
|
231
|
+
out.push(_shape(file, ln, 'k8s-webhook-failure-ignore',
|
|
232
|
+
`${kind} uses failurePolicy: Ignore — admission silently bypassed on webhook outage`,
|
|
233
|
+
'k8s-webhook-bypass', 'high', 'CWE-754',
|
|
234
|
+
'Set failurePolicy: Fail for security-critical webhooks (PodSecurity, image-signing verification, network policy enforcement). Use Ignore only for observability/audit-only webhooks where false-pass is preferable to API-server stalls.',
|
|
235
|
+
'failurePolicy: Ignore means a webhook outage (network blip, pod crash) silently disables the admission check — a perfect window for a bypass attack. The 2022 Argo CD incident exploited exactly this.'));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// sideEffects: None / NoneOnDryRun missing → flag as a separate issue.
|
|
239
|
+
if (!/sideEffects:/.test(body)) {
|
|
240
|
+
const ln = _line(raw, body);
|
|
241
|
+
const id = `k8s-webhook-no-sideeffects:${file}:${ln}`;
|
|
242
|
+
if (!seen.has(id)) {
|
|
243
|
+
seen.add(id);
|
|
244
|
+
out.push(_shape(file, ln, 'k8s-webhook-no-sideeffects',
|
|
245
|
+
`${kind} missing sideEffects declaration`,
|
|
246
|
+
'k8s-webhook-sideeffects', 'low', 'CWE-1287',
|
|
247
|
+
'Add `sideEffects: None` or `sideEffects: NoneOnDryRun`. Required since admissionregistration.k8s.io/v1; missing the field makes dry-run admission unreliable.',
|
|
248
|
+
undefined));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Entry point ────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
export function scanK8sAdmission(fp, raw) {
|
|
257
|
+
if (process.env.AGENTIC_SECURITY_NO_K8S_ADM === '1') return [];
|
|
258
|
+
if (!raw || raw.length > 500_000) return [];
|
|
259
|
+
if (!_IS_K8S_FILE.test(fp)) return [];
|
|
260
|
+
if (!/^(?:apiVersion|kind):/m.test(raw)) return [];
|
|
261
|
+
|
|
262
|
+
const out = [];
|
|
263
|
+
const seen = new Set();
|
|
264
|
+
try { detectRbac(fp, raw, out, seen); } catch {}
|
|
265
|
+
try { detectPodSecurity(fp, raw, out, seen); } catch {}
|
|
266
|
+
try { detectWebhooks(fp, raw, out, seen); } catch {}
|
|
267
|
+
for (const f of out) f.file = fp;
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const _internals = { detectRbac, detectPodSecurity, detectWebhooks, _docKinds };
|