import { createHash } from "node:crypto"; import { readFile, readdir } from "node:fs/promises"; import { dirname, relative, resolve, sep } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const IGNORED_DIRECTORIES = new Set([ ".git", ".mypy_cache", ".next", ".pytest_cache", ".ruff_cache", ".turbo", ".venv", "__pycache__", "coverage", "dist", "node_modules", ]); export const KNOWLEDGE_FS_INTEGRATION_EXACT_PATHS = Object.freeze([ ".github/dependabot.yml", ".github/workflows/knowledge-fs-ci.yml", "api/.env.example", "api/app_factory.py", "api/commands/__init__.py", "api/controllers/console/__init__.py", "api/controllers/console/workspace/rbac.py", "api/controllers/service_api/__init__.py", "api/core/agent/base_agent_runner.py", "api/core/app/apps/agent_app/runtime_request_builder.py", "api/core/rbac/entities.py", "api/core/tools/__base/tool_runtime.py", "api/core/tools/builtin_tool/_position.yaml", "api/core/workflow/node_runtime.py", "api/core/workflow/nodes/agent_v2/runtime_request_builder.py", "api/extensions/ext_celery.py", "api/extensions/ext_commands.py", "api/models/__init__.py", "api/pyproject.toml", "api/services/account_service.py", "api/services/agent_tool_inner_service.py", "api/services/enterprise/rbac_service.py", "api/services/entities/agent_tool_inner.py", "api/tests/unit_tests/controllers/console/workspace/test_rbac.py", "api/tests/unit_tests/core/agent/test_base_agent_runner.py", "api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py", "api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py", "api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py", "api/tests/unit_tests/core/workflow/test_node_runtime.py", "api/tests/unit_tests/services/enterprise/test_rbac_service.py", "api/tests/unit_tests/services/test_account_service.py", "api/tests/unit_tests/services/test_agent_tool_inner_service.py", "api/uv.lock", "dify-agent/src/dify_agent/layers/dify_core_tools/client.py", "dify-agent/tests/local/dify_agent/layers/dify_core_tools/test_client.py", "docker/.env.example", "docker/README.md", "docker/dify-env-sync.py", "docker/dify-env-sync.sh", "docker/docker-compose-template.yaml", "docker/docker-compose.yaml", "docker/envs/core-services/api.env.example", "docker/envs/core-services/knowledge-fs.env.example", "docker/generate_docker_compose", ]); export const KNOWLEDGE_FS_INTEGRATION_PREFIXES = Object.freeze([ Object.freeze({ path: "knowledge-fs/", workflowPattern: "knowledge-fs/**" }), Object.freeze({ path: "packages/contracts/generated/api/console/", workflowPattern: "packages/contracts/generated/api/console/**", }), Object.freeze({ path: "packages/contracts/generated/api/service/", workflowPattern: "packages/contracts/generated/api/service/**", }), ]); const INTEGRATION_EXACT_PATHS = new Set(KNOWLEDGE_FS_INTEGRATION_EXACT_PATHS); const SECRET_RULES = [ { name: "private-key", pattern: /-----BEGIN (?:(?:DSA|EC|ENCRYPTED|OPENSSH|RSA) )?PRIVATE KEY-----/g, }, { name: "aws-access-key-id", pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g }, { name: "github-token", pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})\b/g, }, { name: "google-api-key", pattern: /\bAIza[A-Za-z0-9_-]{32,}\b/g }, { name: "slack-token", pattern: /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g }, { name: "stripe-live-secret", pattern: /\b(?:rk|sk)_live_[A-Za-z0-9]{24,}\b/g }, { name: "openai-api-key", pattern: /\bsk-(?:proj|svcacct)-[A-Za-z0-9_-]{20,}\b/g }, { name: "anthropic-api-key", pattern: /\bsk-ant-api\d{2}-[A-Za-z0-9_-]{20,}\b/g }, { name: "dify-knowledge-fs-credential", pattern: /(? left.name.localeCompare(right.name))) { if (entry.isSymbolicLink()) continue; const absolutePath = resolve(directory, entry.name); if (entry.isDirectory()) { if (!IGNORED_DIRECTORIES.has(entry.name)) files.push(...(await filesUnder(root, absolutePath))); continue; } if (entry.isFile()) files.push(absolutePath); } return files; } export async function scanDirectory(root) { const findings = []; for (const absolutePath of await filesUnder(root)) { const content = await readFile(absolutePath, "utf8"); if (isProbablyBinary(content)) continue; const path = relative(root, absolutePath).split(sep).join("/"); findings.push(...scanText({ content, path })); } return findings.sort(compareFindings); } export function isKnowledgeFsIntegrationPath(path) { const normalizedPath = path.split(sep).join("/"); if ( KNOWLEDGE_FS_INTEGRATION_PREFIXES.some(({ path: prefix }) => normalizedPath.startsWith(prefix)) ) return true; if (INTEGRATION_EXACT_PATHS.has(normalizedPath)) return true; const parts = normalizedPath.split("/"); const basename = parts.at(-1) ?? ""; if ( normalizedPath.startsWith("api/") && (parts.includes("knowledge_fs") || basename.includes("knowledge_fs") || basename.includes("knowledge-fs")) ) { return true; } return normalizedPath.startsWith("docs/design/") && basename.includes("knowledge-fs"); } export async function scanIntegrationSurface(root) { const findings = []; for (const absolutePath of await filesUnder(root)) { const path = relative(root, absolutePath).split(sep).join("/"); if (!isKnowledgeFsIntegrationPath(path)) continue; const content = await readFile(absolutePath, "utf8"); if (isProbablyBinary(content)) continue; findings.push(...scanText({ content, path })); } return findings.sort(compareFindings); } export function parseAllowlist(value) { if ( value === null || typeof value !== "object" || Array.isArray(value) || value.version !== 1 || !Array.isArray(value.entries) ) { throw new Error("Secret scan allowlist must contain version 1 and an entries array."); } const entries = value.entries.map((entry) => { const keys = entry !== null && typeof entry === "object" && !Array.isArray(entry) ? Object.keys(entry).sort() : []; const hasExactKeys = keys.length === ALLOWLIST_ENTRY_KEYS.length && keys.every((key, index) => key === ALLOWLIST_ENTRY_KEYS[index]); if ( !hasExactKeys || typeof entry.path !== "string" || entry.path.length === 0 || entry.path.startsWith("/") || entry.path.split("/").includes("..") || /[*?[\]]/.test(entry.path) || !Number.isInteger(entry.line) || entry.line < 1 || typeof entry.rule !== "string" || !SECRET_RULES.some((rule) => rule.name === entry.rule) || typeof entry.fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(entry.fingerprint) || typeof entry.reason !== "string" || entry.reason.trim().length < 10 ) { throw new Error( "Each secret scan allowlist entry requires an exact path, line, rule, fingerprint, and reason.", ); } return entry; }); const keys = entries.map(allowlistKey); if (new Set(keys).size !== keys.length) { throw new Error("Secret scan allowlist contains a duplicate entry."); } return { entries, version: 1 }; } function allowlistKey(value) { return `${value.path}\0${value.line}\0${value.rule}\0${value.fingerprint}`; } export function evaluateFindings(findings, allowlist) { const findingKeys = new Set(findings.map(allowlistKey)); const allowedKeys = new Set(allowlist.entries.map(allowlistKey)); return { staleAllowlistEntries: allowlist.entries.filter( (entry) => !findingKeys.has(allowlistKey(entry)), ), unallowedFindings: findings.filter((finding) => !allowedKeys.has(allowlistKey(finding))), }; } async function run() { const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repositoryRoot = resolve(packageRoot, ".."); const allowlistPath = resolve(packageRoot, "secret-scan-allowlist.json"); const allowlist = parseAllowlist(JSON.parse(await readFile(allowlistPath, "utf8"))); const findings = await scanIntegrationSurface(repositoryRoot); const result = evaluateFindings(findings, allowlist); for (const finding of result.unallowedFindings) { console.error( `${finding.path}:${finding.line}:${finding.column} ${finding.rule} (${finding.fingerprint})`, ); } for (const entry of result.staleAllowlistEntries) { console.error( `${entry.path}:${entry.line} stale ${entry.rule} allowlist entry (${entry.fingerprint})`, ); } if (result.unallowedFindings.length > 0 || result.staleAllowlistEntries.length > 0) { throw new Error( `Secret scan failed with ${result.unallowedFindings.length} finding(s) and ${result.staleAllowlistEntries.length} stale allowlist entry(s).`, ); } console.log(`Secret scan passed (${findings.length} explicitly allowlisted finding(s)).`); } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { run().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }); }