security-mcp 1.1.1 → 1.1.3
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/README.md +15 -12
- package/dist/ci/pr-gate.js +18 -1
- package/dist/cli/onboarding.js +78 -7
- package/dist/gate/checks/api.js +93 -0
- package/dist/gate/checks/ci-pipeline.js +135 -0
- package/dist/gate/checks/crypto.js +91 -22
- package/dist/gate/checks/database.js +5 -1
- package/dist/gate/checks/dependencies.js +297 -2
- package/dist/gate/checks/dlp.js +6 -1
- package/dist/gate/checks/graphql.js +6 -1
- package/dist/gate/checks/k8s.js +229 -181
- package/dist/gate/checks/nuclei.js +133 -0
- package/dist/gate/checks/runtime.js +32 -18
- package/dist/gate/checks/scanners.js +2 -1
- package/dist/gate/diff.js +2 -0
- package/dist/gate/policy.js +47 -4
- package/dist/gate/result.js +7 -1
- package/dist/mcp/audit-chain.js +253 -0
- package/dist/mcp/learning.js +228 -0
- package/dist/mcp/model-router.js +544 -0
- package/dist/mcp/orchestration.js +22 -4
- package/dist/mcp/server.js +92 -1
- package/dist/review/store.js +10 -0
- package/package.json +1 -1
- package/skills/_TEMPLATE/SKILL.md +99 -0
- package/skills/advanced-dos-tester/SKILL.md +225 -0
- package/skills/ai-model-supply-chain-agent/SKILL.md +198 -0
- package/skills/anti-replay-tester/SKILL.md +195 -0
- package/skills/binary-auth-validator/SKILL.md +184 -0
- package/skills/bot-detection-specialist/SKILL.md +221 -0
- package/skills/capec-code-mapper/SKILL.md +163 -0
- package/skills/cert-pin-rotation-specialist/SKILL.md +200 -0
- package/skills/compliance-lifecycle-tracker/SKILL.md +169 -0
- package/skills/credential-stuffing-specialist/SKILL.md +192 -0
- package/skills/csa-ccm-mapper/SKILL.md +178 -0
- package/skills/csf2-governance-mapper/SKILL.md +159 -0
- package/skills/deep-link-fuzzer/SKILL.md +195 -0
- package/skills/device-integrity-aggregator/SKILL.md +221 -0
- package/skills/dos-resilience-tester/SKILL.md +184 -0
- package/skills/dread-scorer/SKILL.md +157 -0
- package/skills/egress-policy-enforcer/SKILL.md +208 -0
- package/skills/file-upload-attacker/SKILL.md +208 -0
- package/skills/git-history-secret-scanner/SKILL.md +182 -0
- package/skills/iam-privesc-graph-builder/SKILL.md +216 -0
- package/skills/incident-responder/SKILL.md +192 -0
- package/skills/json-ambiguity-tester/SKILL.md +175 -0
- package/skills/kill-switch-engineer/SKILL.md +205 -0
- package/skills/linddun-privacy-analyst/SKILL.md +196 -0
- package/skills/mobile-binary-hardener/SKILL.md +199 -0
- package/skills/mobile-webview-auditor/SKILL.md +200 -0
- package/skills/multipart-abuse-tester/SKILL.md +146 -0
- package/skills/oauth-pkce-specialist/SKILL.md +191 -0
- package/skills/parser-exhaustion-tester/SKILL.md +177 -0
- package/skills/quantum-migration-planner/SKILL.md +184 -0
- package/skills/registry-mirror-enforcer/SKILL.md +142 -0
- package/skills/rotation-validation-agent/SKILL.md +188 -0
- package/skills/samm-assessor/SKILL.md +168 -0
- package/skills/secrets-mask-bypass-tester/SKILL.md +167 -0
- package/skills/session-timeout-tester/SKILL.md +197 -0
- package/skills/slsa-level3-enforcer/SKILL.md +185 -0
- package/skills/slsa-provenance-enforcer/SKILL.md +181 -0
- package/skills/ssrf-detection-validator/SKILL.md +229 -0
- package/skills/step-up-auth-enforcer/SKILL.md +176 -0
- package/skills/threat-infrastructure-analyst/SKILL.md +167 -0
- package/skills/token-reuse-detector/SKILL.md +203 -0
- package/skills/trike-risk-modeler/SKILL.md +139 -0
- package/skills/unicode-homograph-tester/SKILL.md +179 -0
- package/skills/waf-rule-lifecycle-agent/SKILL.md +213 -0
- package/skills/webhook-security-tester/SKILL.md +184 -0
- package/skills/zero-trust-architect/SKILL.md +211 -0
package/README.md
CHANGED
|
@@ -19,8 +19,8 @@ Works with **Claude Code, GitHub Copilot, Cursor, Codex, Replit**, and any MCP-c
|
|
|
19
19
|
|
|
20
20
|
- [What Problem Does This Solve?](#what-problem-does-this-solve)
|
|
21
21
|
- [Who Is This For?](#who-is-this-for)
|
|
22
|
-
- [Two Modes - Pick Your Depth](#two-modes
|
|
23
|
-
- [Quick Start - Install in 60 Seconds](#quick-start
|
|
22
|
+
- [Two Modes - Pick Your Depth](#two-modes---pick-your-depth)
|
|
23
|
+
- [Quick Start - Install in 60 Seconds](#quick-start---install-in-60-seconds)
|
|
24
24
|
- [Step-by-Step Installation Guide](#step-by-step-installation-guide)
|
|
25
25
|
- [Claude Code](#step-by-step-claude-code)
|
|
26
26
|
- [Cursor](#step-by-step-cursor)
|
|
@@ -77,19 +77,19 @@ A single elite security engineer agent that reviews your code, finds vulnerabili
|
|
|
77
77
|
|
|
78
78
|
### `/ciso-orchestrator` - A Full Security Program in One Command
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
39 specialist agents across 3 phases. Phase 1: 7 lead agents run in parallel, each commanding its own team of sub-agents — threat modeling, deep code analysis, cloud infrastructure, supply chain, AI/LLM red team, mobile, and cryptography. Phase 2: adversarial penetration testing and compliance synthesis run in parallel after Phase 1 completes. Phase 3: findings are merged, deduplicated, and attested. Every domain has a dedicated specialist — an injection attacker, a JWT/OAuth hacker, a cloud privilege escalation analyst, a prompt injection specialist, a TLS auditor, a pentest team that reads the threat model as its attack brief, and a compliance analyst mapping every finding to PCI DSS 4.0, SOC 2, ISO 27001, NIST 800-53, HIPAA, and GDPR. Agents learn from each run and improve over time. 86 specialist skills registered in the registry — loaded on demand based on detected stack. Optionally fetches live CVE, CISA KEV, and ATT&CK data. Produces a merged findings report with full compliance mapping and a signed attestation.
|
|
81
81
|
|
|
82
|
-
**Use this before major releases, compliance audits, or security reviews. -> [See the full
|
|
82
|
+
**Use this before major releases, compliance audits, or security reviews. -> [See the full 39-agent architecture](#ciso-orchestrator-flow-39-agents)**
|
|
83
83
|
|
|
84
84
|
---
|
|
85
85
|
|
|
86
86
|
| | `/senior-security-engineer` | `/ciso-orchestrator` |
|
|
87
87
|
| --- | --- | --- |
|
|
88
|
-
| **What it is** | Single expert agent |
|
|
88
|
+
| **What it is** | Single expert agent | 39-agent multi-phase security program |
|
|
89
89
|
| **Best for** | Daily development, PR reviews, targeted hardening | Pre-launch audits, compliance prep, incident response |
|
|
90
90
|
| **Speed** | Seconds to minutes | Minutes to hours |
|
|
91
91
|
| **Scope** | You choose: recent changes, full codebase, or specific files | Always full - every surface, every framework |
|
|
92
|
-
| **Agents** | 1 |
|
|
92
|
+
| **Agents** | 1 | 39 (9 leads + 30 sub-agents) |
|
|
93
93
|
| **Output** | Inline code fixes + SHA-256 attestation | Full domain reports + merged findings + attestation |
|
|
94
94
|
| **API cost** | Low | High |
|
|
95
95
|
| **Internet** | Not required | Optional (enriches findings with live CVEs, CISA KEV, MITRE ATT&CK) |
|
|
@@ -112,7 +112,7 @@ Restart your editor. Then in Claude Code:
|
|
|
112
112
|
|
|
113
113
|
That's it. The engineer will ask how you want to scope the review, then find and fix security issues in your code.
|
|
114
114
|
|
|
115
|
-
For a full
|
|
115
|
+
For a full 39-agent deep audit:
|
|
116
116
|
|
|
117
117
|
```text
|
|
118
118
|
/ciso-orchestrator
|
|
@@ -398,7 +398,7 @@ The orchestrator will ask:
|
|
|
398
398
|
- **Yes** - agents enrich findings with live threat intelligence. More accurate, more current.
|
|
399
399
|
- **No** - agents use cached intel. Still comprehensive, no external calls made.
|
|
400
400
|
|
|
401
|
-
**Step 3 - Wait for Phase 1 (7 lead agents
|
|
401
|
+
**Step 3 - Wait for Phase 1 (7 lead agents running in parallel, each commanding their domain-specific sub-agents — 25 sub-agents total across Phase 1).**
|
|
402
402
|
|
|
403
403
|
Each agent writes findings to `.mcp/agent-runs/{agentRunId}/`.
|
|
404
404
|
|
|
@@ -468,7 +468,7 @@ The gate runs **18 checks in parallel** against your diff:
|
|
|
468
468
|
| Category | What It Catches |
|
|
469
469
|
| --- | --- |
|
|
470
470
|
| **Secrets** | Hardcoded API keys, tokens, passwords, private keys (via Gitleaks patterns) |
|
|
471
|
-
| **Dependencies** | CRITICAL/HIGH CVEs in npm/pip/go/maven packages; CISA
|
|
471
|
+
| **Dependencies** | CRITICAL/HIGH CVEs in npm/pip/go/maven packages; CISA KEV cross-check and EPSS >50% auto-escalation via live threat-intel (24h cached) |
|
|
472
472
|
| **Cryptography** | MD5, SHA-1, DES, RC4, ECB mode, `Math.random()` for tokens, short JWT secrets |
|
|
473
473
|
| **Authentication** | Missing rate limiting, no account lockout, JWT `alg:none`, weak session config |
|
|
474
474
|
| **Injection** | SQL, NoSQL, command injection, path traversal, SSRF, prototype pollution |
|
|
@@ -485,6 +485,7 @@ The gate runs **18 checks in parallel** against your diff:
|
|
|
485
485
|
| **Runtime** | HTTP security headers and TLS config on live staging URL (if configured) |
|
|
486
486
|
| **AI red-team** | Static + optional dynamic probes against AI endpoints |
|
|
487
487
|
| **Exceptions** | Validates any active security exceptions are non-expired and properly approved |
|
|
488
|
+
| **Baseline regression** | Detects when previously-satisfied controls go missing (BASELINE_REGRESSION HIGH finding injected on regression) |
|
|
488
489
|
|
|
489
490
|
### Customize the Gate Policy
|
|
490
491
|
|
|
@@ -620,7 +621,7 @@ app.use(helmet({
|
|
|
620
621
|
│ Your Editor (Claude Code) │
|
|
621
622
|
│ │
|
|
622
623
|
│ /senior-security-engineer /ciso-orchestrator │
|
|
623
|
-
│ (single expert agent) (
|
|
624
|
+
│ (single expert agent) (39-agent security program) │
|
|
624
625
|
│ │ │ │
|
|
625
626
|
└──────────┼────────────────────────────────┼───────────────────┘
|
|
626
627
|
│ │
|
|
@@ -701,7 +702,7 @@ User: /senior-security-engineer
|
|
|
701
702
|
└── SHA-256 integrity hash
|
|
702
703
|
```
|
|
703
704
|
|
|
704
|
-
### `/ciso-orchestrator` Flow (
|
|
705
|
+
### `/ciso-orchestrator` Flow (39 Agents)
|
|
705
706
|
|
|
706
707
|
```text
|
|
707
708
|
User: /ciso-orchestrator
|
|
@@ -715,7 +716,7 @@ User: /ciso-orchestrator
|
|
|
715
716
|
│ -> stackContext: { languages, frameworks, cloudProvider, hasAI, hasMobile, ... }
|
|
716
717
|
├── security.start_review() -> runId
|
|
717
718
|
├── orchestration.create_agent_run() -> agentRunId + manifest.json
|
|
718
|
-
└── orchestration.ensure_skill(
|
|
719
|
+
└── orchestration.ensure_skill(×N) -> download stack-relevant skills from 86-skill registry
|
|
719
720
|
│
|
|
720
721
|
▼
|
|
721
722
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
@@ -994,6 +995,8 @@ Edit `.mcp/exceptions/security-exceptions.json`:
|
|
|
994
995
|
| `SECURITY_GATE_POLICY` | `.mcp/policies/security-policy.json` | Path to policy file |
|
|
995
996
|
| `SECURITY_GATE_SCANNERS` | built-in | Path to custom scanner config (must be within project directory) |
|
|
996
997
|
| `SECURITY_GATE_EXCEPTIONS` | `.mcp/exceptions/security-exceptions.json` | Path to exceptions file (must be within project directory) |
|
|
998
|
+
| `SECURITY_GATE_MODE` | `full` | Set to `file_by_file` for scoped per-file scanning |
|
|
999
|
+
| `SECURITY_GATE_TARGETS` | (all changed files) | Comma-separated file paths to restrict the scan surface |
|
|
997
1000
|
|
|
998
1001
|
### Integrations (all optional)
|
|
999
1002
|
|
package/dist/ci/pr-gate.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { runPrGate } from "../gate/policy.js";
|
|
2
2
|
// Allow safe git revision operators (~ and ^) plus ref/path characters. CWE-88.
|
|
3
3
|
const SAFE_REF_RE = /^[a-zA-Z0-9_./~^-]+$/;
|
|
4
|
+
// Allow relative file/folder paths for targets. CWE-88.
|
|
5
|
+
const SAFE_TARGET_RE = /^[a-zA-Z0-9_./ -]+$/;
|
|
4
6
|
function safeEnvRef(envVar, defaultValue) {
|
|
5
7
|
const val = process.env[envVar] || defaultValue;
|
|
6
8
|
if (!SAFE_REF_RE.test(val)) {
|
|
@@ -9,11 +11,26 @@ function safeEnvRef(envVar, defaultValue) {
|
|
|
9
11
|
}
|
|
10
12
|
return val;
|
|
11
13
|
}
|
|
14
|
+
function safeEnvTargets(envVar) {
|
|
15
|
+
const raw = process.env[envVar];
|
|
16
|
+
if (!raw)
|
|
17
|
+
return undefined;
|
|
18
|
+
const targets = raw.split(",").map((t) => t.trim()).filter(Boolean);
|
|
19
|
+
return targets.filter((t) => {
|
|
20
|
+
if (!SAFE_TARGET_RE.test(t) || t.includes("..")) {
|
|
21
|
+
console.error(`Skipping unsafe target: "${t}"`);
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
12
27
|
async function main() {
|
|
13
28
|
const baseRef = safeEnvRef("SECURITY_GATE_BASE_REF", "origin/main");
|
|
14
29
|
const headRef = safeEnvRef("SECURITY_GATE_HEAD_REF", "HEAD");
|
|
15
30
|
const policyPath = process.env.SECURITY_GATE_POLICY || ".mcp/policies/security-policy.json";
|
|
16
|
-
const
|
|
31
|
+
const mode = (process.env.SECURITY_GATE_MODE ?? "recent_changes");
|
|
32
|
+
const targets = safeEnvTargets("SECURITY_GATE_TARGETS");
|
|
33
|
+
const result = await runPrGate({ baseRef, headRef, policyPath, mode, targets });
|
|
17
34
|
// Print result for Actions logs
|
|
18
35
|
console.log(JSON.stringify(result, null, 2));
|
|
19
36
|
if (result.status !== "PASS") {
|
package/dist/cli/onboarding.js
CHANGED
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
import { createInterface } from "node:readline/promises";
|
|
14
14
|
import { stdin as input, stdout as output } from "node:process";
|
|
15
15
|
import { spawnSync } from "node:child_process";
|
|
16
|
-
import { platform, arch, homedir } from "node:os";
|
|
17
|
-
import { mkdirSync, createWriteStream, chmodSync, existsSync } from "node:fs";
|
|
16
|
+
import { platform, arch, homedir, tmpdir } from "node:os";
|
|
17
|
+
import { mkdirSync, createWriteStream, chmodSync, existsSync, writeFileSync, unlinkSync } from "node:fs";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { pipeline } from "node:stream/promises";
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { readFile as readFileAsync } from "node:fs/promises";
|
|
20
22
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
21
23
|
const PROJECT_TYPES = [
|
|
22
24
|
{
|
|
@@ -220,6 +222,39 @@ function run(cmd, args) {
|
|
|
220
222
|
const result = spawnSync(cmd, args, { stdio: "inherit" });
|
|
221
223
|
return result.status === 0;
|
|
222
224
|
}
|
|
225
|
+
// ─── Binary integrity helpers ─────────────────────────────────────────────────
|
|
226
|
+
// CWE-494: verify downloaded binary against publisher SHA-256 checksum before install.
|
|
227
|
+
async function fetchChecksumFile(assets) {
|
|
228
|
+
const checksumAsset = assets.find((a) => /checksums?\.txt$/i.test(a.name) || /\.sha256(sums?)?$/i.test(a.name));
|
|
229
|
+
if (!checksumAsset)
|
|
230
|
+
return null;
|
|
231
|
+
try {
|
|
232
|
+
const res = await fetch(checksumAsset.browser_download_url);
|
|
233
|
+
if (!res.ok)
|
|
234
|
+
return null;
|
|
235
|
+
return await res.text();
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function parseExpectedHash(checksumContent, filename) {
|
|
242
|
+
for (const line of checksumContent.split("\n")) {
|
|
243
|
+
const parts = line.trim().split(/\s+/);
|
|
244
|
+
if (parts.length >= 2) {
|
|
245
|
+
const hash = parts[0];
|
|
246
|
+
const name = (parts.at(-1) ?? "").replace(/^\*/, "");
|
|
247
|
+
if (name === filename && /^[0-9a-f]{64}$/i.test(hash)) {
|
|
248
|
+
return hash.toLowerCase();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
async function verifyIntegrity(filePath, expectedHash) {
|
|
255
|
+
const content = await readFileAsync(filePath);
|
|
256
|
+
return createHash("sha256").update(content).digest("hex") === expectedHash;
|
|
257
|
+
}
|
|
223
258
|
// ─── GitHub binary download ───────────────────────────────────────────────────
|
|
224
259
|
async function fetchLatestRelease(repo) {
|
|
225
260
|
try {
|
|
@@ -282,6 +317,29 @@ async function installFromGitHub(tool, os) {
|
|
|
282
317
|
print(` Download failed.`);
|
|
283
318
|
return false;
|
|
284
319
|
}
|
|
320
|
+
// CWE-494: verify SHA-256 integrity before executing anything
|
|
321
|
+
const checksumContent = await fetchChecksumFile(release.assets);
|
|
322
|
+
if (checksumContent) {
|
|
323
|
+
const expectedHash = parseExpectedHash(checksumContent, fileName);
|
|
324
|
+
if (expectedHash) {
|
|
325
|
+
const valid = await verifyIntegrity(tmpFile, expectedHash);
|
|
326
|
+
if (!valid) {
|
|
327
|
+
print(` Integrity check FAILED for ${fileName} — aborting install.`);
|
|
328
|
+
try {
|
|
329
|
+
unlinkSync(tmpFile);
|
|
330
|
+
}
|
|
331
|
+
catch { /* ignore cleanup failure */ }
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
print(` Integrity verified (SHA-256 matched).`);
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
print(` Warning: checksum file found but no entry for ${fileName} — proceeding without verification.`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
print(` Warning: no checksum file in release assets — cannot verify binary integrity.`);
|
|
342
|
+
}
|
|
285
343
|
const destDir = "/usr/local/bin";
|
|
286
344
|
if (tool.tarball) {
|
|
287
345
|
// Extract the binary from the archive
|
|
@@ -347,12 +405,24 @@ async function tryDnf(tool) {
|
|
|
347
405
|
const mgr = commandExists("dnf") ? "dnf" : commandExists("yum") ? "yum" : null;
|
|
348
406
|
if (!mgr)
|
|
349
407
|
return false;
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
"
|
|
408
|
+
// CWE-78: avoid bash -c shell construction — write repo file to a temp path
|
|
409
|
+
// then move it into place with sudo (no shell, no injection surface).
|
|
410
|
+
const repoLines = [
|
|
411
|
+
"[trivy]",
|
|
412
|
+
"name=Trivy repository",
|
|
413
|
+
"baseurl=https://aquasecurity.github.io/trivy-repo/rpm/releases/$releasever/$basearch/",
|
|
414
|
+
"gpgcheck=0",
|
|
415
|
+
"enabled=1"
|
|
416
|
+
];
|
|
417
|
+
const tmpRepoFile = join(tmpdir(), `trivy-${Date.now()}.repo`);
|
|
354
418
|
print(` Adding Aqua Security yum/dnf repository...`);
|
|
355
|
-
|
|
419
|
+
try {
|
|
420
|
+
writeFileSync(tmpRepoFile, repoLines.join("\n") + "\n", "utf-8");
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
run("sudo", ["mv", tmpRepoFile, "/etc/yum.repos.d/trivy.repo"]);
|
|
356
426
|
print(` sudo ${mgr} install -y trivy`);
|
|
357
427
|
return run("sudo", [mgr, "install", "-y", "trivy"]);
|
|
358
428
|
}
|
|
@@ -513,6 +583,7 @@ export async function runOnboarding() {
|
|
|
513
583
|
print("");
|
|
514
584
|
print(" This applies the right compliance controls automatically,");
|
|
515
585
|
print(" such as PCI DSS for payment cards or HIPAA for health data.");
|
|
586
|
+
print(" You can select multiple options (e.g. 1 2 or 1,2).");
|
|
516
587
|
print("");
|
|
517
588
|
for (const d of SENSITIVE_DATA_OPTIONS) {
|
|
518
589
|
print(` ${d.key}. ${d.label}`);
|
package/dist/gate/checks/api.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { sanitizeErrorMessage } from "../result.js";
|
|
1
2
|
import { searchRepo } from "../../repo/search.js";
|
|
3
|
+
import fg from "fast-glob";
|
|
4
|
+
import { readFileSafe } from "../../repo/fs.js";
|
|
2
5
|
export async function checkApi(_) {
|
|
3
6
|
const findings = [];
|
|
4
7
|
const zodHits = await searchRepo({ query: "zod|valibot|yup|joi", isRegex: true, maxMatches: 200 });
|
|
@@ -135,5 +138,95 @@ export async function checkApi(_) {
|
|
|
135
138
|
]
|
|
136
139
|
});
|
|
137
140
|
}
|
|
141
|
+
// 5. API schema drift (OpenAPI/Swagger spec vs code routes)
|
|
142
|
+
findings.push(...await checkApiSchemaDrift());
|
|
143
|
+
return findings;
|
|
144
|
+
}
|
|
145
|
+
function parseDeclaredPaths(specContent) {
|
|
146
|
+
const paths = new Set();
|
|
147
|
+
for (const match of specContent.matchAll(/^\s{0,4}(\/[a-zA-Z0-9/{}_-]+)\s*:/gm)) {
|
|
148
|
+
paths.add(match[1]);
|
|
149
|
+
}
|
|
150
|
+
return paths;
|
|
151
|
+
}
|
|
152
|
+
function findShadowRoutes(codeRouteHits, declaredPaths) {
|
|
153
|
+
const shadows = [];
|
|
154
|
+
for (const hit of codeRouteHits) {
|
|
155
|
+
const routeMatch = /['"](\/?[a-zA-Z0-9/{}_-]+)['"]/.exec(hit.preview);
|
|
156
|
+
if (!routeMatch)
|
|
157
|
+
continue;
|
|
158
|
+
const route = routeMatch[1].startsWith("/") ? routeMatch[1] : `/${routeMatch[1]}`;
|
|
159
|
+
const normalised = route.replaceAll(/:([a-zA-Z_]+)/g, "{$1}");
|
|
160
|
+
if (!declaredPaths.has(normalised) && !declaredPaths.has(route)) {
|
|
161
|
+
shadows.push(`${hit.file}:${hit.line} — ${route}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return shadows;
|
|
165
|
+
}
|
|
166
|
+
async function checkApiSchemaDrift() {
|
|
167
|
+
const findings = [];
|
|
168
|
+
try {
|
|
169
|
+
const specFiles = await fg([
|
|
170
|
+
"openapi.{yaml,yml,json}",
|
|
171
|
+
"swagger.{yaml,yml,json}",
|
|
172
|
+
"**/openapi.{yaml,yml,json}",
|
|
173
|
+
"**/swagger.{yaml,yml,json}",
|
|
174
|
+
"**/api-spec.{yaml,yml,json}",
|
|
175
|
+
"**/openapi/**/*.{yaml,yml,json}"
|
|
176
|
+
], { ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**"], dot: true });
|
|
177
|
+
const codeRouteHits = await searchRepo({
|
|
178
|
+
query: String.raw `(?:router|app|fastify|server)\.(?:get|post|put|delete|patch)\s*\(\s*['"](/[^'"]+)['"]`,
|
|
179
|
+
isRegex: true,
|
|
180
|
+
maxMatches: 300
|
|
181
|
+
});
|
|
182
|
+
if (specFiles.length === 0) {
|
|
183
|
+
if (codeRouteHits.length > 0) {
|
|
184
|
+
findings.push({
|
|
185
|
+
id: "API_NO_OPENAPI_SPEC",
|
|
186
|
+
title: "API routes detected but no OpenAPI/Swagger specification found",
|
|
187
|
+
severity: "MEDIUM",
|
|
188
|
+
evidence: codeRouteHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
189
|
+
requiredActions: [
|
|
190
|
+
"Create an OpenAPI 3.x specification (openapi.yaml) that documents all API routes.",
|
|
191
|
+
"An API contract enables automated schema validation, client SDK generation, and drift detection.",
|
|
192
|
+
"Use tools like `zod-to-openapi` or `tsoa` to generate the spec from existing TypeScript code."
|
|
193
|
+
]
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return findings;
|
|
197
|
+
}
|
|
198
|
+
const specContent = await readFileSafe(specFiles[0]);
|
|
199
|
+
const declaredPaths = parseDeclaredPaths(specContent);
|
|
200
|
+
const shadowRoutes = findShadowRoutes(codeRouteHits, declaredPaths);
|
|
201
|
+
if (shadowRoutes.length > 0) {
|
|
202
|
+
findings.push({
|
|
203
|
+
id: "API_SHADOW_ENDPOINT",
|
|
204
|
+
title: `${shadowRoutes.length} API route(s) in code not declared in OpenAPI spec — shadow endpoints`,
|
|
205
|
+
severity: "HIGH",
|
|
206
|
+
evidence: [...new Set(shadowRoutes)].slice(0, 15),
|
|
207
|
+
requiredActions: [
|
|
208
|
+
"Add all undocumented routes to the OpenAPI specification.",
|
|
209
|
+
"Shadow endpoints bypass API gateway policies, rate limiting, and schema validation.",
|
|
210
|
+
"Automate spec generation (tsoa, zod-to-openapi) to prevent drift from recurring."
|
|
211
|
+
]
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
if (/type:\s+object/.test(specContent) && !/properties:/.test(specContent)) {
|
|
215
|
+
findings.push({
|
|
216
|
+
id: "API_PERMISSIVE_SCHEMA",
|
|
217
|
+
title: "OpenAPI spec contains `type: object` without `properties` — accepts any payload shape",
|
|
218
|
+
severity: "MEDIUM",
|
|
219
|
+
files: [specFiles[0]],
|
|
220
|
+
requiredActions: [
|
|
221
|
+
"Define explicit `properties` for all object schemas in the OpenAPI spec.",
|
|
222
|
+
"Permissive schemas allow attackers to inject unexpected fields (mass assignment, prototype pollution).",
|
|
223
|
+
"Set `additionalProperties: false` on request body schemas to enforce strict validation."
|
|
224
|
+
]
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
console.warn("[checkApiSchemaDrift] Internal error:", sanitizeErrorMessage(err instanceof Error ? err.message : String(err)));
|
|
230
|
+
}
|
|
138
231
|
return findings;
|
|
139
232
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions CI/CD pipeline hardening checks.
|
|
3
|
+
* Covers supply chain attack vectors specific to GitHub Actions workflows.
|
|
4
|
+
*/
|
|
5
|
+
import { sanitizeErrorMessage } from "../result.js";
|
|
6
|
+
import fg from "fast-glob";
|
|
7
|
+
import { readFileSafe } from "../../repo/fs.js";
|
|
8
|
+
export async function runCiPipelineChecks(_opts) {
|
|
9
|
+
const findings = [];
|
|
10
|
+
try {
|
|
11
|
+
const workflowFiles = await fg([".github/workflows/*.yml", ".github/workflows/*.yaml"], {
|
|
12
|
+
dot: true,
|
|
13
|
+
ignore: ["**/node_modules/**", "**/.git/**"]
|
|
14
|
+
});
|
|
15
|
+
if (workflowFiles.length === 0) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
const unpinnedFiles = [];
|
|
19
|
+
const pwnTargetFiles = [];
|
|
20
|
+
const secretEchoFiles = [];
|
|
21
|
+
const noPermissionsFiles = [];
|
|
22
|
+
const selfHostedFiles = [];
|
|
23
|
+
for (const file of workflowFiles) {
|
|
24
|
+
let content;
|
|
25
|
+
try {
|
|
26
|
+
content = await readFileSafe(file);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
// Check 1: Third-party actions not pinned to a full 40-char SHA
|
|
32
|
+
// Matches `uses: owner/repo@tag` but NOT `uses: owner/repo@<40hex chars>`
|
|
33
|
+
// Also skip `uses: ./.github/actions/` (local actions are fine)
|
|
34
|
+
const actionLines = content.split("\n").filter((line) => /uses:\s+[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+@/.test(line));
|
|
35
|
+
const unpinnedActions = actionLines.filter((line) => {
|
|
36
|
+
// Skip local actions
|
|
37
|
+
if (/uses:\s+\.\//.test(line))
|
|
38
|
+
return false;
|
|
39
|
+
// Flag anything not pinned to a 40-char hex SHA
|
|
40
|
+
return !/uses:\s+[a-zA-Z0-9_.\-/]+@[0-9a-f]{40}/.test(line);
|
|
41
|
+
});
|
|
42
|
+
if (unpinnedActions.length > 0) {
|
|
43
|
+
unpinnedFiles.push(file);
|
|
44
|
+
}
|
|
45
|
+
// Check 2: pull_request_target + dynamic ref usage (pwn-request vector)
|
|
46
|
+
// Attacker controls the ref/sha when a PR from a fork triggers pull_request_target
|
|
47
|
+
if (/pull_request_target/.test(content) &&
|
|
48
|
+
/\$\{\{\s*github\.event\.pull_request\.head\.(sha|ref)\s*\}\}/.test(content)) {
|
|
49
|
+
pwnTargetFiles.push(file);
|
|
50
|
+
}
|
|
51
|
+
// Check 3: Secrets printed to logs via echo
|
|
52
|
+
if (/echo\s+\$\{\{\s*secrets\./.test(content)) {
|
|
53
|
+
secretEchoFiles.push(file);
|
|
54
|
+
}
|
|
55
|
+
// Check 4: No top-level permissions block
|
|
56
|
+
// Without explicit permissions, the default is write access to all scopes
|
|
57
|
+
if (!/^permissions:/m.test(content)) {
|
|
58
|
+
noPermissionsFiles.push(file);
|
|
59
|
+
}
|
|
60
|
+
// Check 5: Self-hosted runners (broader attack surface — runner compromise = code execution)
|
|
61
|
+
if (/runs-on:\s+self-hosted/.test(content)) {
|
|
62
|
+
selfHostedFiles.push(file);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (unpinnedFiles.length > 0) {
|
|
66
|
+
findings.push({
|
|
67
|
+
id: "CI_UNPINNED_ACTION",
|
|
68
|
+
title: "GitHub Actions using mutable tags instead of pinned SHA digests",
|
|
69
|
+
severity: "HIGH",
|
|
70
|
+
files: unpinnedFiles.slice(0, 10),
|
|
71
|
+
requiredActions: [
|
|
72
|
+
"Pin all third-party actions to a full 40-character commit SHA (e.g. `uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683`).",
|
|
73
|
+
"Mutable tags like @v3 can be silently redirected to malicious commits — SHA pinning prevents supply chain substitution.",
|
|
74
|
+
"Use a tool like `pin-github-action` or Dependabot to automate SHA pinning."
|
|
75
|
+
]
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (pwnTargetFiles.length > 0) {
|
|
79
|
+
findings.push({
|
|
80
|
+
id: "CI_PWNTARGET_SHA",
|
|
81
|
+
title: "pull_request_target workflow uses attacker-controlled ref/SHA — pwn-request vector",
|
|
82
|
+
severity: "CRITICAL",
|
|
83
|
+
files: pwnTargetFiles.slice(0, 10),
|
|
84
|
+
requiredActions: [
|
|
85
|
+
"Never use `${{ github.event.pull_request.head.sha }}` or `head.ref` inside a `pull_request_target` workflow that checks out or runs code from the PR.",
|
|
86
|
+
"`pull_request_target` runs with write permissions and secrets access; the PR head is attacker-controlled.",
|
|
87
|
+
"Use `pull_request` (not `pull_request_target`) for code that executes untrusted contributions, or add explicit guard conditions."
|
|
88
|
+
]
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (secretEchoFiles.length > 0) {
|
|
92
|
+
findings.push({
|
|
93
|
+
id: "CI_SECRET_ECHO",
|
|
94
|
+
title: "GitHub Actions workflow echoes secrets to logs",
|
|
95
|
+
severity: "CRITICAL",
|
|
96
|
+
files: secretEchoFiles.slice(0, 10),
|
|
97
|
+
requiredActions: [
|
|
98
|
+
"Remove any `echo ${{ secrets.* }}` statements — secrets printed to logs are visible to anyone with read access to the repository.",
|
|
99
|
+
"GitHub masks known secret values in logs, but this is not reliable for all encodings.",
|
|
100
|
+
"Pass secrets via environment variables (`env: MY_SECRET: ${{ secrets.MY_SECRET }}`) and read them in code, never echo them."
|
|
101
|
+
]
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (noPermissionsFiles.length > 0) {
|
|
105
|
+
findings.push({
|
|
106
|
+
id: "CI_NO_PERMISSIONS",
|
|
107
|
+
title: "GitHub Actions workflows without explicit permissions block",
|
|
108
|
+
severity: "MEDIUM",
|
|
109
|
+
files: noPermissionsFiles.slice(0, 10),
|
|
110
|
+
requiredActions: [
|
|
111
|
+
"Add an explicit `permissions:` block at the top of each workflow (or at the job level) to grant only the minimum required scopes.",
|
|
112
|
+
"Without explicit permissions, the default is determined by org/repo settings — often write-all.",
|
|
113
|
+
"Example minimal read-only: `permissions: { contents: read }`."
|
|
114
|
+
]
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (selfHostedFiles.length > 0) {
|
|
118
|
+
findings.push({
|
|
119
|
+
id: "CI_SELF_HOSTED_RUNNER",
|
|
120
|
+
title: "GitHub Actions using self-hosted runners",
|
|
121
|
+
severity: "MEDIUM",
|
|
122
|
+
files: selfHostedFiles.slice(0, 10),
|
|
123
|
+
requiredActions: [
|
|
124
|
+
"Self-hosted runners executing untrusted fork PRs can be compromised — restrict `pull_request` triggers on self-hosted runner workflows.",
|
|
125
|
+
"Ensure self-hosted runners are ephemeral (destroyed after each job) and isolated from production networks.",
|
|
126
|
+
"Use GitHub-hosted runners for public repositories or untrusted code paths."
|
|
127
|
+
]
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
console.warn("[runCiPipelineChecks] Internal error:", sanitizeErrorMessage(err instanceof Error ? err.message : String(err)));
|
|
133
|
+
}
|
|
134
|
+
return findings;
|
|
135
|
+
}
|
|
@@ -1,4 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weak cryptography detection.
|
|
3
|
+
* Mapped to NIST SP 800-131A Rev 2.
|
|
4
|
+
*/
|
|
5
|
+
import { sanitizeErrorMessage } from "../result.js";
|
|
1
6
|
import { searchRepo } from "../../repo/search.js";
|
|
7
|
+
function checkPbkdf2Iterations(hits) {
|
|
8
|
+
for (const hit of hits) {
|
|
9
|
+
const iterMatch = /pbkdf2(?:Sync)?\s*\([^)]*?,\s*[^,]+,\s*(\d+)/.exec(hit.preview);
|
|
10
|
+
if (!iterMatch)
|
|
11
|
+
continue;
|
|
12
|
+
const iters = Number.parseInt(iterMatch[1], 10);
|
|
13
|
+
if (iters < 600000) {
|
|
14
|
+
return {
|
|
15
|
+
id: "CRYPTO_LOW_PBKDF2_ITERATIONS",
|
|
16
|
+
title: `PBKDF2 iteration count too low (${iters} < 600,000)`,
|
|
17
|
+
severity: "HIGH",
|
|
18
|
+
evidence: [`${hit.file}:${hit.line}:${hit.preview}`],
|
|
19
|
+
files: [hit.file],
|
|
20
|
+
requiredActions: [
|
|
21
|
+
"Use ≥ 600,000 iterations for PBKDF2-SHA256 (OWASP 2023 recommendation).",
|
|
22
|
+
"Prefer bcrypt (cost ≥ 12) or Argon2id instead."
|
|
23
|
+
]
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
2
29
|
export async function checkCrypto(_opts) {
|
|
3
30
|
const findings = [];
|
|
4
31
|
try {
|
|
@@ -86,27 +113,9 @@ export async function checkCrypto(_opts) {
|
|
|
86
113
|
isRegex: true,
|
|
87
114
|
maxMatches: 200
|
|
88
115
|
});
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (iterMatch) {
|
|
93
|
-
const iters = parseInt(iterMatch[1], 10);
|
|
94
|
-
if (iters < 600000) {
|
|
95
|
-
findings.push({
|
|
96
|
-
id: "CRYPTO_LOW_PBKDF2_ITERATIONS",
|
|
97
|
-
title: `PBKDF2 iteration count too low (${iters} < 600,000)`,
|
|
98
|
-
severity: "HIGH",
|
|
99
|
-
evidence: [`${hit.file}:${hit.line}:${hit.preview}`],
|
|
100
|
-
files: [hit.file],
|
|
101
|
-
requiredActions: [
|
|
102
|
-
"Use ≥ 600,000 iterations for PBKDF2-SHA256 (OWASP 2023 recommendation).",
|
|
103
|
-
"Prefer bcrypt (cost ≥ 12) or Argon2id instead."
|
|
104
|
-
]
|
|
105
|
-
});
|
|
106
|
-
break;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
116
|
+
const pbkdf2Finding = checkPbkdf2Iterations(pbkdf2Hits);
|
|
117
|
+
if (pbkdf2Finding)
|
|
118
|
+
findings.push(pbkdf2Finding);
|
|
110
119
|
// 6. Hardcoded IV/nonce
|
|
111
120
|
const hardcodedIvHits = await searchRepo({
|
|
112
121
|
query: String.raw `iv\s*[:=]\s*(?:Buffer\.from\(['"][0-9a-fA-F]+['"]\)|['"][0-9a-fA-F]{16,}['"])`,
|
|
@@ -145,9 +154,69 @@ export async function checkCrypto(_opts) {
|
|
|
145
154
|
]
|
|
146
155
|
});
|
|
147
156
|
}
|
|
157
|
+
// 8. Post-quantum readiness: RSA-1024
|
|
158
|
+
const rsa1024Hits = await searchRepo({
|
|
159
|
+
query: String.raw `modulusLength\s*:\s*1024|generateKeyPair\s*\(\s*['"]rsa['"][^)]*1024`,
|
|
160
|
+
isRegex: true,
|
|
161
|
+
maxMatches: 200
|
|
162
|
+
});
|
|
163
|
+
if (rsa1024Hits.length > 0) {
|
|
164
|
+
findings.push({
|
|
165
|
+
id: "CRYPTO_RSA_1024",
|
|
166
|
+
title: "RSA-1024 key detected — cryptographically broken",
|
|
167
|
+
severity: "CRITICAL",
|
|
168
|
+
evidence: rsa1024Hits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
169
|
+
files: [...new Set(rsa1024Hits.slice(0, 10).map((m) => m.file))],
|
|
170
|
+
requiredActions: [
|
|
171
|
+
"Upgrade to RSA-4096 minimum, or migrate to ML-DSA (FIPS 204) / SLH-DSA (FIPS 205) for new key material.",
|
|
172
|
+
"RSA-1024 is fully broken — NIST deprecated it in 2013 (SP 800-131A).",
|
|
173
|
+
"For TLS certificates, reissue with RSA-4096 or ECDSA P-384 immediately."
|
|
174
|
+
]
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
// 9. Post-quantum readiness: RSA-2048 warning
|
|
178
|
+
const rsa2048Hits = await searchRepo({
|
|
179
|
+
query: String.raw `modulusLength\s*:\s*2048|generateKeyPair\s*\(\s*['"]rsa['"][^)]*2048`,
|
|
180
|
+
isRegex: true,
|
|
181
|
+
maxMatches: 200
|
|
182
|
+
});
|
|
183
|
+
if (rsa2048Hits.length > 0) {
|
|
184
|
+
findings.push({
|
|
185
|
+
id: "CRYPTO_RSA_2048_PQC",
|
|
186
|
+
title: "RSA-2048 detected — quantum-vulnerable; plan migration to post-quantum algorithms",
|
|
187
|
+
severity: "MEDIUM",
|
|
188
|
+
evidence: rsa2048Hits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
189
|
+
files: [...new Set(rsa2048Hits.slice(0, 10).map((m) => m.file))],
|
|
190
|
+
requiredActions: [
|
|
191
|
+
"RSA-2048 is currently secure against classical computers but will be broken by sufficiently large quantum computers.",
|
|
192
|
+
"NIST finalized post-quantum standards in 2024: ML-KEM (FIPS 203), ML-DSA (FIPS 204), SLH-DSA (FIPS 205).",
|
|
193
|
+
"For long-lived keys or data requiring 10+ year secrecy: migrate to ML-DSA or use a hybrid classical+PQC scheme."
|
|
194
|
+
]
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
// 10. Post-quantum readiness: ECDSA P-256 (informational)
|
|
198
|
+
const p256Hits = await searchRepo({
|
|
199
|
+
query: String.raw `prime256v1|secp256r1|namedCurve\s*:\s*['"]P-256['"]|namedCurve\s*:\s*['"]p256['"]`,
|
|
200
|
+
isRegex: true,
|
|
201
|
+
maxMatches: 200
|
|
202
|
+
});
|
|
203
|
+
if (p256Hits.length > 0) {
|
|
204
|
+
findings.push({
|
|
205
|
+
id: "CRYPTO_ECDSA_P256_PQC",
|
|
206
|
+
title: "ECDSA P-256 detected — quantum-vulnerable in the long term",
|
|
207
|
+
severity: "LOW",
|
|
208
|
+
evidence: p256Hits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
209
|
+
files: [...new Set(p256Hits.slice(0, 10).map((m) => m.file))],
|
|
210
|
+
requiredActions: [
|
|
211
|
+
"P-256 (secp256r1) is secure today but vulnerable to Shor's algorithm on a sufficiently large quantum computer.",
|
|
212
|
+
"NIST post-quantum signature standards: ML-DSA (FIPS 204) and SLH-DSA (FIPS 205) are the recommended replacements.",
|
|
213
|
+
"For new systems handling sensitive long-lived data, evaluate hybrid ECDSA+ML-DSA or pure ML-DSA."
|
|
214
|
+
]
|
|
215
|
+
});
|
|
216
|
+
}
|
|
148
217
|
}
|
|
149
218
|
catch (err) {
|
|
150
|
-
console.warn("[checkCrypto] Internal error:", err instanceof Error ? err.message : String(err));
|
|
219
|
+
console.warn("[checkCrypto] Internal error:", sanitizeErrorMessage(err instanceof Error ? err.message : String(err)));
|
|
151
220
|
}
|
|
152
221
|
return findings;
|
|
153
222
|
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database security checks.
|
|
3
|
+
*/
|
|
4
|
+
import { sanitizeErrorMessage } from "../result.js";
|
|
1
5
|
import { searchRepo } from "../../repo/search.js";
|
|
2
6
|
export async function checkDatabase(_opts) {
|
|
3
7
|
const findings = [];
|
|
@@ -138,7 +142,7 @@ export async function checkDatabase(_opts) {
|
|
|
138
142
|
}
|
|
139
143
|
}
|
|
140
144
|
catch (err) {
|
|
141
|
-
console.warn("[checkDatabase] Internal error:", err instanceof Error ? err.message : String(err));
|
|
145
|
+
console.warn("[checkDatabase] Internal error:", sanitizeErrorMessage(err instanceof Error ? err.message : String(err)));
|
|
142
146
|
}
|
|
143
147
|
return findings;
|
|
144
148
|
}
|