dsh-code-security 0.2.2

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/lib/index.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * dsh-code-security —— AI 代码安全审查插件(node 半身,配置走 cordis.patch.yml)。
3
+ *
4
+ * 插件导出 apply(ctx, config):注册 secure_scan / secure_diff / secure_fix_verify /
5
+ * secure_report / secure_export / secure_policy_show / secure_policy_set 七个工具。扫描为纯确定性规则,
6
+ * git diff 通过官方 subprocess 服务执行,零运行时依赖。
7
+ *
8
+ * @module dsh-code-security
9
+ */
10
+ import { resolveConfig } from './config.js';
11
+ import { createSubprocessRunner } from './runner.js';
12
+ import { buildSecureTools } from './tools.js';
13
+ export const inject = ['tools', 'subprocess'];
14
+ export function apply(ctx, config) {
15
+ let cfg;
16
+ try {
17
+ cfg = resolveConfig(config);
18
+ }
19
+ catch (error) {
20
+ console.warn('[dsh-code-security] ' + (error instanceof Error ? error.message : String(error)));
21
+ cfg = resolveConfig(null);
22
+ }
23
+ const runner = createSubprocessRunner(ctx.subprocess.spawn, 10000, 60000);
24
+ const tools = buildSecureTools(cfg, process.cwd(), runner);
25
+ const disposers = [];
26
+ for (const definition of tools) {
27
+ const wrapped = { ...definition };
28
+ if (wrapped.gate !== undefined) {
29
+ const original = wrapped.gate.bind(wrapped);
30
+ wrapped.gate = async (exec, next) => {
31
+ const record = (typeof exec === 'object' && exec !== null ? exec : {});
32
+ return original({ ...record, approval: ctx.get?.('approval') }, next);
33
+ };
34
+ }
35
+ disposers.push(ctx.tools.register(wrapped, { prepend: true }));
36
+ }
37
+ if (typeof ctx.on === 'function') {
38
+ ctx.on('dispose', () => { for (const dispose of disposers)
39
+ dispose(); });
40
+ }
41
+ }
42
+ export * from './config.js';
43
+ export * from './deps.js';
44
+ export * from './diff.js';
45
+ export * from './policy.js';
46
+ export * from './rules.js';
47
+ export * from './runner.js';
48
+ export * from './scanner.js';
49
+ export * from './state.js';
50
+ export * from './extra.js';
51
+ export * from './tools.js';
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 项目策略文件 .code-security.json:排除目录、忽略规则与门禁阈值。
3
+ *
4
+ * @module dsh-code-security/policy
5
+ */
6
+ export interface PolicyIgnore {
7
+ ruleId?: string;
8
+ file?: string;
9
+ reason?: string;
10
+ }
11
+ export interface SecurePolicy {
12
+ version: 1;
13
+ exclude: string[];
14
+ ignore: PolicyIgnore[];
15
+ failOn?: 'critical' | 'high' | 'medium' | 'low';
16
+ }
17
+ export declare const DEFAULT_POLICY: SecurePolicy;
18
+ export declare const POLICY_FILE = ".code-security.json";
19
+ export declare function policyPath(cwd: string): string;
20
+ export declare function loadPolicy(cwd: string): Promise<SecurePolicy>;
21
+ export declare function normalizePolicy(obj: Record<string, unknown>): SecurePolicy;
22
+ export declare function savePolicy(cwd: string, policy: SecurePolicy): Promise<string>;
23
+ export declare function policyExcludesDir(policy: SecurePolicy, rel: string): boolean;
24
+ export declare function policyIgnores(policy: SecurePolicy, ruleId: string, file: string): boolean;
package/lib/policy.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 项目策略文件 .code-security.json:排除目录、忽略规则与门禁阈值。
3
+ *
4
+ * @module dsh-code-security/policy
5
+ */
6
+ import fs from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ export const DEFAULT_POLICY = Object.freeze({
9
+ version: 1,
10
+ exclude: [],
11
+ ignore: [],
12
+ });
13
+ export const POLICY_FILE = '.code-security.json';
14
+ export function policyPath(cwd) {
15
+ return path.join(cwd, POLICY_FILE);
16
+ }
17
+ export async function loadPolicy(cwd) {
18
+ try {
19
+ const text = await fs.readFile(policyPath(cwd), 'utf8');
20
+ const parsed = JSON.parse(text);
21
+ const obj = (parsed ?? {});
22
+ return normalizePolicy(obj);
23
+ }
24
+ catch {
25
+ return { ...DEFAULT_POLICY, exclude: [...DEFAULT_POLICY.exclude], ignore: [] };
26
+ }
27
+ }
28
+ export function normalizePolicy(obj) {
29
+ const exclude = Array.isArray(obj.exclude) ? obj.exclude.filter((x) => typeof x === 'string') : [];
30
+ const ignoreRaw = Array.isArray(obj.ignore) ? obj.ignore : [];
31
+ const ignore = [];
32
+ for (const item of ignoreRaw) {
33
+ if (typeof item !== 'object' || item === null)
34
+ continue;
35
+ const rec = item;
36
+ ignore.push({
37
+ ruleId: typeof rec.ruleId === 'string' && rec.ruleId !== '' ? rec.ruleId : undefined,
38
+ file: typeof rec.file === 'string' && rec.file !== '' ? rec.file : undefined,
39
+ reason: typeof rec.reason === 'string' ? rec.reason : '',
40
+ });
41
+ }
42
+ const failOn = obj.failOn === 'critical' || obj.failOn === 'high' || obj.failOn === 'medium' || obj.failOn === 'low' ? obj.failOn : undefined;
43
+ return { version: 1, exclude, ignore, failOn };
44
+ }
45
+ export async function savePolicy(cwd, policy) {
46
+ const file = policyPath(cwd);
47
+ const tmp = file + '.tmp-' + process.pid;
48
+ await fs.writeFile(tmp, JSON.stringify(policy, null, 2) + '\n', 'utf8');
49
+ await fs.rename(tmp, file);
50
+ return file;
51
+ }
52
+ function globToRegex(glob) {
53
+ const escaped = glob.replace(/[.+^$()|{}]/g, '\\$&').replace(/\*\*/g, '§§').replace(/\*/g, '[^/]*').replace(/§§/g, '.*');
54
+ return new RegExp('(^|/)' + escaped + '($|/)');
55
+ }
56
+ export function policyExcludesDir(policy, rel) {
57
+ return policy.exclude.some((pattern) => globToRegex(pattern).test(rel));
58
+ }
59
+ export function policyIgnores(policy, ruleId, file) {
60
+ return policy.ignore.some((item) => {
61
+ if (item.ruleId !== undefined && item.ruleId !== ruleId)
62
+ return false;
63
+ if (item.file !== undefined && !globToRegex(item.file).test(file))
64
+ return false;
65
+ return true;
66
+ });
67
+ }
package/lib/rules.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 确定性安全规则库。每条规则只报告事实模式,不包含修复建议。
3
+ *
4
+ * @module dsh-code-security/rules
5
+ */
6
+ import type { Severity } from './config.js';
7
+ export type Language = 'javascript' | 'python' | 'go' | 'java' | 'ruby' | 'php' | 'shell' | 'yaml' | 'dockerfile' | 'json' | 'env' | 'any';
8
+ export interface SecurityRule {
9
+ id: string;
10
+ title: string;
11
+ cwe: string;
12
+ severity: Severity;
13
+ confidence: 'high' | 'medium' | 'low';
14
+ languages: Language[];
15
+ patterns: RegExp[];
16
+ message: string;
17
+ fileLevel?: boolean;
18
+ }
19
+ export declare const RULES: readonly SecurityRule[];
20
+ /** 规则类别:供报告聚合与排序使用。 */
21
+ export declare function ruleCategory(ruleId: string): string;
22
+ export declare function languageForFile(file: string): Language;
package/lib/rules.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 确定性安全规则库。每条规则只报告事实模式,不包含修复建议。
3
+ *
4
+ * @module dsh-code-security/rules
5
+ */
6
+ const JS = ['javascript'];
7
+ const PY = ['python'];
8
+ const JAVA = ['java'];
9
+ const SHELL = ['shell'];
10
+ const YAML = ['yaml'];
11
+ const DOCKER = ['dockerfile'];
12
+ const ALL = ['any'];
13
+ export const RULES = Object.freeze([
14
+ { id: 'SEC-001', title: 'JavaScript eval / Function 动态执行', cwe: 'CWE-95', severity: 'critical', confidence: 'high', languages: JS, patterns: [/\beval\s*\(/, /\bnew\s+Function\s*\(/], message: '发现 eval 或 new Function 动态代码执行。' },
15
+ { id: 'SEC-002', title: 'Node.js exec 使用 shell 执行命令', cwe: 'CWE-78', severity: 'high', confidence: 'high', languages: JS, patterns: [/\bexecSync\s*\(/, /child_process\s*\.\s*exec(?:Sync)?\s*\(/], message: 'child_process exec/execSync 经 shell 解释命令字符串。' },
16
+ { id: 'SEC-003', title: 'Node.js spawn shell: true', cwe: 'CWE-78', severity: 'high', confidence: 'high', languages: JS, patterns: [/\bspawn(?:Sync)?\s*\([^)]*\{\s*shell\s*:\s*true/], message: 'spawn 显式启用了 shell 解释。' },
17
+ { id: 'SEC-004', title: 'Python subprocess shell=True', cwe: 'CWE-78', severity: 'critical', confidence: 'high', languages: PY, patterns: [/\bsubprocess\.(?:run|call|Popen|check_output|check_call)\s*\([^)]*shell\s*=\s*True/], message: 'subprocess 调用启用 shell=True。' },
18
+ { id: 'SEC-005', title: 'Python os.system / popen', cwe: 'CWE-78', severity: 'high', confidence: 'high', languages: PY, patterns: [/\bos\.(?:system|popen)\s*\(/], message: '使用 os.system/os.popen 执行 shell 命令。' },
19
+ { id: 'SEC-006', title: 'Shell 管道下载执行', cwe: 'CWE-78', severity: 'critical', confidence: 'high', languages: SHELL, patterns: [/\bcurl\b[^|\n]*\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b/i, /\bwget\b[^|\n]*\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b/i], message: '下载内容直接通过管道交给 shell 执行。' },
20
+ { id: 'SEC-007', title: 'JavaScript SQL 字符串拼接', cwe: 'CWE-89', severity: 'high', confidence: 'medium', languages: JS, patterns: [/['"](?:SELECT|INSERT|UPDATE|DELETE)\b[^'"]*['"]\s*\+/i, /\bquery\s*=\s*['"][^'"]*['"]\s*\+/i], message: 'SQL 语句与变量拼接,疑似可注入。' },
21
+ { id: 'SEC-008', title: 'Python SQL 字符串格式化', cwe: 'CWE-89', severity: 'high', confidence: 'medium', languages: PY, patterns: [/\.execute\s*\([^)]*(?:%\s*\(|%\s*[a-z_]|\bf\s*['"]|\+\s*str\()/i], message: 'SQL 执行语句使用字符串格式化或拼接。' },
22
+ { id: 'SEC-009', title: 'JavaScript innerHTML / 危险 HTML 注入', cwe: 'CWE-79', severity: 'high', confidence: 'medium', languages: JS, patterns: [/\.innerHTML\s*=/, /\bdangerouslySetInnerHTML\b/], message: '将不可信内容写入 HTML 插值点。' },
23
+ { id: 'SEC-010', title: '开放重定向', cwe: 'CWE-601', severity: 'medium', confidence: 'medium', languages: JS, patterns: [/\bredirect\s*\(\s*(?:req|request)\.(?:query|params|body)/i, /\blocation\s*=\s*(?:req|request)\.(?:query|params|body)/i], message: '跳转目标直接取自请求参数。' },
24
+ { id: 'SEC-101', title: 'Python pickle 反序列化', cwe: 'CWE-502', severity: 'critical', confidence: 'high', languages: PY, patterns: [/\bpickle\.(?:loads?)\s*\(/], message: 'pickle 反序列化不可信数据。' },
25
+ { id: 'SEC-102', title: 'Python yaml.load 不安全解析', cwe: 'CWE-502', severity: 'high', confidence: 'medium', languages: PY, patterns: [/\byaml\.load\s*\(/], message: 'yaml.load 未使用 SafeLoader。' },
26
+ { id: 'SEC-103', title: 'Java 原生反序列化', cwe: 'CWE-502', severity: 'high', confidence: 'medium', languages: JAVA, patterns: [/\bObjectInputStream\b/, /\.readObject\s*\(/], message: '使用 Java 原生对象反序列化。' },
27
+ { id: 'SEC-104', title: 'Ruby 危险反序列化', cwe: 'CWE-502', severity: 'high', confidence: 'medium', languages: ['ruby'], patterns: [/\bMarshal\.load\s*\(/, /\bYAML\.load\s*\(/], message: 'Ruby 反序列化不可信对象。' },
28
+ { id: 'SEC-105', title: 'PHP 反序列化', cwe: 'CWE-502', severity: 'high', confidence: 'medium', languages: ['php'], patterns: [/\bunserialize\s*\(/], message: 'unserialize 处理不可信输入。' },
29
+ { id: 'SEC-201', title: '弱哈希用于安全场景', cwe: 'CWE-328', severity: 'high', confidence: 'medium', languages: ALL, patterns: [/\bcreateHash\s*\(\s*['"]?(?:md5|sha1)['"]?/i, /\bhashlib\.(?:md5|sha1)\s*\(/i, /\b(?:md5|sha1)(?:sum)?\s+/i], message: '使用 MD5 / SHA1 弱哈希。' },
30
+ { id: 'SEC-202', title: 'ECB 分组加密模式', cwe: 'CWE-327', severity: 'high', confidence: 'medium', languages: ALL, patterns: [/aes[-_/]?\d*[-_/]?ecb/i, /['"]AES\/ECB/i], message: '使用 ECB 加密模式。' },
31
+ { id: 'SEC-203', title: '硬编码 IV / 密钥', cwe: 'CWE-321', severity: 'high', confidence: 'medium', languages: ALL, patterns: [/\.createCipheriv?\s*\([^)]*['"][A-Za-z0-9+/=]{12,}['"]/, /\b(?:iv|key|secret)\s*[:=]\s*['"][A-Za-z0-9+/=]{16,}['"]/i], message: '加密密钥或 IV 以字面量硬编码。' },
32
+ { id: 'SEC-204', title: 'JWT alg none / 不校验签名', cwe: 'CWE-347', severity: 'critical', confidence: 'high', languages: JS, patterns: [/['"]?alg['"]?\s*[:=]\s*['"]?none['"]?/i, /\bjwt\.verify\s*\([^)]*\{\s*algorithms\s*:\s*\[\s*['"]none/i], message: 'JWT 允许 none 算法或未校验签名。' },
33
+ { id: 'SEC-205', title: 'TLS 校验被禁用', cwe: 'CWE-295', severity: 'high', confidence: 'high', languages: ALL, patterns: [/rejectUnauthorized\s*:\s*false/, /\bverify\s*=\s*False/, /InsecureSkipVerify\s*:\s*true/], message: 'TLS 证书校验被显式关闭。' },
34
+ { id: 'SEC-206', title: 'Math.random 用于安全敏感逻辑', cwe: 'CWE-330', severity: 'low', confidence: 'low', languages: JS, patterns: [/\bMath\.random\s*\(/], message: '使用非密码学安全的 Math.random。' },
35
+ { id: 'SEC-301', title: '硬编码密码', cwe: 'CWE-798', severity: 'critical', confidence: 'high', languages: ALL, patterns: [/(?:password|passwd|pwd)\s*[:=]\s*['"][^'"]{4,}['"]/i], message: '密码以明文硬编码在源码中。' },
36
+ { id: 'SEC-302', title: '硬编码令牌 / API 密钥', cwe: 'CWE-798', severity: 'high', confidence: 'medium', languages: ALL, patterns: [/(?:api[_-]?key|secret[_-]?key|access[_-]?key|auth[_-]?token)\s*[:=]\s*['"][A-Za-z0-9_\-]{16,}['"]/i], message: '访问令牌或 API 密钥以字面量硬编码。' },
37
+ { id: 'SEC-303', title: '私钥材料', cwe: 'CWE-798', severity: 'critical', confidence: 'high', languages: ALL, patterns: [/-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/], message: '检测到私钥文件内容。', fileLevel: true },
38
+ { id: 'SEC-304', title: '疑似高熵密钥', cwe: 'CWE-798', severity: 'medium', confidence: 'medium', languages: ALL, patterns: [], message: '检测到高熵字符串,疑似密钥或令牌。' },
39
+ { id: 'SEC-401', title: 'chmod 777', cwe: 'CWE-732', severity: 'high', confidence: 'high', languages: ALL, patterns: [/(?:chmod|os\.chmod|fs\.chmod)\b[^\n;]*(?:777|0o777|0o?0777)/, /chmod\s+(?:-R\s+)?777/], message: '文件权限设置为 777。' },
40
+ { id: 'SEC-402', title: 'Docker privileged 模式', cwe: 'CWE-250', severity: 'high', confidence: 'high', languages: DOCKER, patterns: [/--privileged/], message: '容器以 privileged 特权模式运行。' },
41
+ { id: 'SEC-403', title: 'CORS 通配符', cwe: 'CWE-942', severity: 'medium', confidence: 'medium', languages: JS, patterns: [/(?:origin|allowedOrigins?)\s*[:=]\s*['"]?\*['"]?/i], message: 'CORS 允许任意来源。' },
42
+ { id: 'SEC-404', title: 'npm 禁用安全审计', cwe: 'CWE-693', severity: 'medium', confidence: 'high', languages: ['json', 'any'], patterns: [/"audit"\s*:\s*false/i, /^audit\s*=\s*false\b/im], message: '依赖审计被显式关闭。', fileLevel: true },
43
+ { id: 'SEC-405', title: '依赖安装使用 force / legacy-peer-deps', cwe: 'CWE-693', severity: 'medium', confidence: 'high', languages: SHELL, patterns: [/--force\b/, /--legacy-peer-deps\b/], message: '包安装使用了会绕过安全/兼容门禁的标志。' },
44
+ { id: 'SEC-406', title: 'Docker 使用 latest 标签', cwe: 'CWE-1104', severity: 'low', confidence: 'high', languages: DOCKER, patterns: [/\bFROM\s+\S+:latest\b/i], message: '基础镜像使用 latest 浮动标签。' },
45
+ { id: 'SEC-501', title: '日志打印敏感变量', cwe: 'CWE-532', severity: 'medium', confidence: 'medium', languages: ALL, patterns: [/(?:console\.log|log\.(?:info|debug)|print|printf|log\b)\s*\([^)]*(?:password|secret|token|api[_-]?key)/i], message: '日志输出包含疑似敏感变量。' },
46
+ { id: 'SEC-502', title: '错误信息包含内部细节', cwe: 'CWE-209', severity: 'low', confidence: 'medium', languages: JS, patterns: [/\.send\s*\(\s*(?:err|error)\.stack/i, /\bres\.(?:json|send)\s*\(\s*(?:err|error)\.message/i], message: '错误响应直接返回堆栈或内部错误消息。' },
47
+ { id: 'SEC-601', title: '路径拼接来自请求参数', cwe: 'CWE-22', severity: 'high', confidence: 'medium', languages: JS, patterns: [/(?:path\.join|readFile(?:Sync)?|writeFile(?:Sync)?|open(?:Sync)?)\s*\([^)]*(?:req|request)\.(?:query|params|body)/i], message: '文件路径由请求参数参与拼接。' },
48
+ { id: 'SEC-602', title: 'SSRF:请求目标来自用户输入', cwe: 'CWE-918', severity: 'high', confidence: 'medium', languages: JS, patterns: [/(?:fetch|axios\.get|axios\(|request\()\s*\(\s*(?:req|request)\.(?:query|params|body)/i], message: '请求 URL 直接取自用户输入。' },
49
+ { id: 'SEC-011', title: 'PHP 命令执行函数', cwe: 'CWE-78', severity: 'critical', confidence: 'high', languages: ['php'], patterns: [/\b(?:system|exec|shell_exec|passthru|proc_open)\s*\(/], message: '使用 PHP 命令执行函数。' },
50
+ { id: 'SEC-012', title: 'Ruby 命令执行函数', cwe: 'CWE-78', severity: 'high', confidence: 'high', languages: ['ruby'], patterns: [/\bsystem\s*\(/, /\bexec\s*\(/, /\bIO\.popen\s*\(/], message: '使用 Ruby 命令执行接口。' },
51
+ { id: 'SEC-013', title: 'Java Runtime.exec / ProcessBuilder', cwe: 'CWE-78', severity: 'high', confidence: 'medium', languages: JAVA, patterns: [/Runtime\.getRuntime\(\)\.exec\s*\(/, /\bnew\s+ProcessBuilder\s*\(/], message: '使用 Java 进程执行接口。' },
52
+ { id: 'SEC-014', title: 'Python eval / exec', cwe: 'CWE-95', severity: 'critical', confidence: 'high', languages: PY, patterns: [/\beval\s*\(/, /\bexec\s*\(/], message: '使用 Python 动态代码执行。' },
53
+ { id: 'SEC-015', title: 'tarfile.extractall 路径穿越', cwe: 'CWE-22', severity: 'high', confidence: 'medium', languages: PY, patterns: [/\.extractall\s*\(/], message: '压缩包提取未做成员路径校验。' },
54
+ { id: 'SEC-016', title: 'JavaScript document.write', cwe: 'CWE-79', severity: 'high', confidence: 'medium', languages: JS, patterns: [/\bdocument\.write\s*\(/], message: '使用 document.write 写入动态内容。' },
55
+ { id: 'SEC-017', title: 'Python 请求目标来自用户输入', cwe: 'CWE-918', severity: 'high', confidence: 'medium', languages: PY, patterns: [/\brequests\.(?:get|post|request)\s*\([^)]*(?:request|params|form|data|input)/i], message: 'HTTP 请求目标可能来自用户输入。' },
56
+ { id: 'SEC-305', title: '.env 包含敏感项', cwe: 'CWE-312', severity: 'high', confidence: 'medium', languages: ['env'], patterns: [/^(?:[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS)[A-Z0-9_]*)\s*=\s*.+/im], message: '.env 文件中包含敏感配置项。', fileLevel: true },
57
+ { id: 'SEC-306', title: 'Node crypto.createCipher 已废弃', cwe: 'CWE-327', severity: 'high', confidence: 'high', languages: JS, patterns: [/\bcrypto\.createCipher\s*\(/], message: '使用已废弃的非认证加密接口。' },
58
+ { id: 'SEC-407', title: 'GitHub Actions 使用浮动分支', cwe: 'CWE-1104', severity: 'low', confidence: 'high', languages: YAML, patterns: [/\buses\s*:\s*\S+@(?:main|master)\b/i], message: 'Action 未锁定到提交 SHA 或版本标签。' },
59
+ { id: 'SEC-408', title: 'Docker USER root', cwe: 'CWE-250', severity: 'low', confidence: 'high', languages: DOCKER, patterns: [/^\s*USER\s+root\b/im], message: '容器默认以 root 用户运行。' },
60
+ { id: 'SEC-603', title: 'Python 文件路径来自用户输入', cwe: 'CWE-22', severity: 'high', confidence: 'medium', languages: PY, patterns: [/\bopen\s*\([^)]*(?:request|params|form|input|filename)/i, /\bos\.path\.join\s*\([^)]*(?:request|params|form|input)/i], message: '文件路径可能由用户输入参与。' },
61
+ ]);
62
+ /** 规则类别:供报告聚合与排序使用。 */
63
+ export function ruleCategory(ruleId) {
64
+ const group = ruleId.slice(4, 6);
65
+ const map = {
66
+ '00': '注入',
67
+ '01': '反序列化',
68
+ '02': '加密与密钥',
69
+ '03': '凭据泄露',
70
+ '04': '配置与权限',
71
+ '05': '日志与信息泄露',
72
+ '06': '路径与网络',
73
+ };
74
+ return map[group] ?? '其他';
75
+ }
76
+ export function languageForFile(file) {
77
+ const name = file.toLowerCase();
78
+ if (/\.(?:js|mjs|cjs|jsx|ts|tsx)$/.test(name))
79
+ return 'javascript';
80
+ if (/\.py$/.test(name))
81
+ return 'python';
82
+ if (/\.go$/.test(name))
83
+ return 'go';
84
+ if (/\.java$/.test(name))
85
+ return 'java';
86
+ if (/\.rb$/.test(name))
87
+ return 'ruby';
88
+ if (/\.php$/.test(name))
89
+ return 'php';
90
+ if (/\.(?:sh|bash|zsh)$/.test(name) || name.endsWith('.bashrc') || name.endsWith('.zshrc'))
91
+ return 'shell';
92
+ if (/\.(?:ya?ml)$/.test(name))
93
+ return 'yaml';
94
+ if (name.endsWith('dockerfile'))
95
+ return 'dockerfile';
96
+ if (/\.json$/.test(name))
97
+ return 'json';
98
+ if (/\.env(?:\..*)?$/.test(name))
99
+ return 'env';
100
+ return 'any';
101
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 子进程执行层:通过 DSH 官方 subprocess 服务运行 git diff。
3
+ *
4
+ * @module dsh-code-security/runner
5
+ */
6
+ export interface RunResult {
7
+ exitCode: number | null;
8
+ signal: string | null;
9
+ stdout: string;
10
+ stderr: string;
11
+ }
12
+ export interface ProcessRunner {
13
+ run(argv: readonly string[], options?: {
14
+ timeoutMs?: number;
15
+ }): Promise<RunResult>;
16
+ }
17
+ export interface SubprocessHandleLike {
18
+ done: Promise<{
19
+ exitCode: number | null;
20
+ signal: string | null;
21
+ }>;
22
+ collected: {
23
+ stdout?: {
24
+ readFrom(offset: number): {
25
+ text: string;
26
+ };
27
+ };
28
+ stderr?: {
29
+ readFrom(offset: number): {
30
+ text: string;
31
+ };
32
+ };
33
+ };
34
+ terminate(): void;
35
+ }
36
+ export interface SubprocessSpawnLike {
37
+ (spec: {
38
+ argv: readonly string[];
39
+ cwd: string;
40
+ stdio: {
41
+ stdin: 'ignore';
42
+ stdout: {
43
+ maxBytes: number;
44
+ };
45
+ stderr: {
46
+ maxBytes: number;
47
+ };
48
+ };
49
+ graceMs: number;
50
+ signal?: AbortSignal;
51
+ }): SubprocessHandleLike;
52
+ }
53
+ export declare function createSubprocessRunner(spawn: SubprocessSpawnLike, graceMs: number, defaultTimeoutMs: number): ProcessRunner;
package/lib/runner.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 子进程执行层:通过 DSH 官方 subprocess 服务运行 git diff。
3
+ *
4
+ * @module dsh-code-security/runner
5
+ */
6
+ const COLLECT_BYTES = 8 * 1024 * 1024;
7
+ export function createSubprocessRunner(spawn, graceMs, defaultTimeoutMs) {
8
+ return {
9
+ async run(argv, options) {
10
+ const timeoutMs = options?.timeoutMs ?? defaultTimeoutMs;
11
+ const controller = new AbortController();
12
+ const timer = setTimeout(() => controller.abort(new Error('git diff timed out')), timeoutMs);
13
+ let handle;
14
+ try {
15
+ handle = spawn({
16
+ argv,
17
+ cwd: process.cwd(),
18
+ stdio: { stdin: 'ignore', stdout: { maxBytes: COLLECT_BYTES }, stderr: { maxBytes: COLLECT_BYTES } },
19
+ graceMs,
20
+ signal: controller.signal,
21
+ });
22
+ }
23
+ finally {
24
+ clearTimeout(timer);
25
+ }
26
+ const outcome = await handle.done;
27
+ return {
28
+ exitCode: outcome.exitCode,
29
+ signal: outcome.signal,
30
+ stdout: handle.collected.stdout?.readFrom(0).text ?? '',
31
+ stderr: handle.collected.stderr?.readFrom(0).text ?? '',
32
+ };
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * 确定性安全扫描器:遍历文件、按语言应用规则、密钥熵检测、内联豁免。
3
+ *
4
+ * @module dsh-code-security/scanner
5
+ */
6
+ import { type Severity } from './config.js';
7
+ import { type SecurePolicy } from './policy.js';
8
+ export interface Finding {
9
+ id: string;
10
+ ruleId: string;
11
+ title: string;
12
+ cwe: string;
13
+ severity: Severity;
14
+ confidence: 'high' | 'medium' | 'low';
15
+ file: string;
16
+ line: number;
17
+ snippet: string;
18
+ message: string;
19
+ }
20
+ export interface ScanResult {
21
+ root: string;
22
+ filesScanned: number;
23
+ filesSkipped: number;
24
+ findings: Finding[];
25
+ durationMs: number;
26
+ }
27
+ export interface ScanOptions {
28
+ cwd: string;
29
+ target?: string;
30
+ maxFiles: number;
31
+ maxFileBytes: number;
32
+ policy: SecurePolicy;
33
+ }
34
+ export interface LineEntry {
35
+ line: number;
36
+ text: string;
37
+ }
38
+ /** 对带行号的文本行应用规则(全量扫描与 git diff 增量扫描共用)。 */
39
+ export declare function scanLineSet(entries: LineEntry[], rel: string, policy: SecurePolicy): Finding[];
40
+ /** 扫描单个文件内容,按语言与行应用规则。 */
41
+ export declare function scanText(content: string, file: string, rel: string, policy: SecurePolicy): Finding[];
42
+ /** 扫描目录或单个文件。 */
43
+ export declare function scanPath(options: ScanOptions): Promise<ScanResult>;
package/lib/scanner.js ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * 确定性安全扫描器:遍历文件、按语言应用规则、密钥熵检测、内联豁免。
3
+ *
4
+ * @module dsh-code-security/scanner
5
+ */
6
+ import fs from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { createHash } from 'node:crypto';
9
+ import { DEFAULT_EXCLUDE_DIRS } from './config.js';
10
+ import { languageForFile, RULES } from './rules.js';
11
+ import { policyExcludesDir, policyIgnores } from './policy.js';
12
+ const DISABLE_NEXT = /secure-review-disable-next-line(?:\s+([\w,-]+))?/;
13
+ const DISABLE_LINE = /secure-review-disable-line(?:\s+([\w,-]+))?/;
14
+ function isComment(line) {
15
+ const t = line.trim();
16
+ return t.startsWith('//') || t.startsWith('#') || t.startsWith('/*') || t.startsWith('*');
17
+ }
18
+ function disabledRules(line, pattern) {
19
+ const m = pattern.exec(line);
20
+ if (m === null)
21
+ return null;
22
+ if (m[1] === undefined)
23
+ return [];
24
+ return m[1].split(',').map((x) => x.trim()).filter((x) => x !== '');
25
+ }
26
+ function shouldSkipLine(rule, disabled) {
27
+ if (disabled === null || disabled === undefined)
28
+ return false;
29
+ if (disabled.length === 0)
30
+ return true;
31
+ return disabled.includes(rule.id);
32
+ }
33
+ function fileHasBinary(buffer) {
34
+ const sample = buffer.subarray(0, Math.min(buffer.length, 8000));
35
+ for (const byte of sample)
36
+ if (byte === 0)
37
+ return true;
38
+ return false;
39
+ }
40
+ const TEXT_EXTENSIONS = /\.(?:js|mjs|cjs|jsx|ts|tsx|py|go|java|rb|php|sh|bash|zsh|ya?ml|json|env|dockerfile|txt|md|html|css|vue|svelte)$/i;
41
+ function shouldScanFile(file) {
42
+ const base = path.basename(file).toLowerCase();
43
+ if (base === 'dockerfile')
44
+ return true;
45
+ if (/\.env(?:\..*)?$/.test(base))
46
+ return true;
47
+ if (base === '.npmrc')
48
+ return true;
49
+ return TEXT_EXTENSIONS.test(file);
50
+ }
51
+ /** Shannon 熵;只对 24 位以上且以字母数字为主的 token 判分。 */
52
+ function shannon(text) {
53
+ if (text.length < 16)
54
+ return 0;
55
+ const counts = new Map();
56
+ for (const char of text)
57
+ counts.set(char, (counts.get(char) ?? 0) + 1);
58
+ let entropy = 0;
59
+ for (const count of counts.values()) {
60
+ const p = count / text.length;
61
+ entropy -= p * Math.log2(p);
62
+ }
63
+ return entropy;
64
+ }
65
+ const TOKEN_PATTERN = /['"]([A-Za-z0-9+/=_-]{24,})['"]/g;
66
+ const HIGH_ENTROPY_THRESHOLD = 3.8;
67
+ function detectHighEntropy(line, lineNumber, file, rel, rule) {
68
+ const findings = [];
69
+ for (const match of line.matchAll(TOKEN_PATTERN)) {
70
+ const token = match[1];
71
+ if (/^[0-9]+$/.test(token))
72
+ continue;
73
+ if (!/\d/.test(token))
74
+ continue;
75
+ if (shannon(token) < HIGH_ENTROPY_THRESHOLD)
76
+ continue;
77
+ const id = makeId(rule.id, rel, lineNumber, token.slice(0, 32));
78
+ findings.push({
79
+ id,
80
+ ruleId: rule.id,
81
+ title: rule.title,
82
+ cwe: rule.cwe,
83
+ severity: rule.severity,
84
+ confidence: rule.confidence,
85
+ file: rel,
86
+ line: lineNumber,
87
+ snippet: line.trim().slice(0, 200),
88
+ message: rule.message,
89
+ });
90
+ }
91
+ return findings;
92
+ }
93
+ function makeId(ruleId, file, line, snippet) {
94
+ const hash = createHash('sha256').update(ruleId + '|' + file + '|' + line + '|' + snippet.slice(0, 80)).digest('hex').slice(0, 10);
95
+ return 'SR-' + hash;
96
+ }
97
+ /** 对带行号的文本行应用规则(全量扫描与 git diff 增量扫描共用)。 */
98
+ export function scanLineSet(entries, rel, policy) {
99
+ const language = languageForFile(rel);
100
+ const findings = [];
101
+ const applied = RULES.filter((rule) => rule.languages.includes(language) || rule.languages.includes('any'));
102
+ const prevDisable = new Map();
103
+ for (const entry of entries) {
104
+ if (isComment(entry.text))
105
+ prevDisable.set(entry.line + 1, disabledRules(entry.text, DISABLE_NEXT));
106
+ }
107
+ for (const entry of entries) {
108
+ const currentDisable = isComment(entry.text) ? disabledRules(entry.text, DISABLE_LINE) : null;
109
+ for (const rule of applied) {
110
+ if (rule.id === 'SEC-304')
111
+ continue;
112
+ if (policyIgnores(policy, rule.id, rel))
113
+ continue;
114
+ if (shouldSkipLine(rule, currentDisable) || shouldSkipLine(rule, prevDisable.get(entry.line)))
115
+ continue;
116
+ if (rule.fileLevel === true)
117
+ continue;
118
+ if (!rule.patterns.some((pattern) => pattern.test(entry.text)))
119
+ continue;
120
+ findings.push({
121
+ id: makeId(rule.id, rel, entry.line, entry.text),
122
+ ruleId: rule.id,
123
+ title: rule.title,
124
+ cwe: rule.cwe,
125
+ severity: rule.severity,
126
+ confidence: rule.confidence,
127
+ file: rel,
128
+ line: entry.line,
129
+ snippet: entry.text.trim().slice(0, 200),
130
+ message: rule.message,
131
+ });
132
+ }
133
+ const entropyRule = RULES.find((rule) => rule.id === 'SEC-304');
134
+ if (entropyRule !== undefined && !policyIgnores(policy, entropyRule.id, rel)) {
135
+ findings.push(...detectHighEntropy(entry.text, entry.line, rel, rel, entropyRule));
136
+ }
137
+ }
138
+ return findings;
139
+ }
140
+ /** 扫描单个文件内容,按语言与行应用规则。 */
141
+ export function scanText(content, file, rel, policy) {
142
+ const lines = content.split(/\r?\n/);
143
+ const entries = lines.map((text, index) => ({ line: index + 1, text }));
144
+ const findings = scanLineSet(entries, rel, policy);
145
+ const language = languageForFile(rel);
146
+ const applied = RULES.filter((rule) => rule.languages.includes(language) || rule.languages.includes('any'));
147
+ for (const rule of applied) {
148
+ if (rule.fileLevel !== true)
149
+ continue;
150
+ if (policyIgnores(policy, rule.id, rel))
151
+ continue;
152
+ if (rule.patterns.some((pattern) => pattern.test(content))) {
153
+ findings.push({
154
+ id: makeId(rule.id, rel, 1, content.slice(0, 80)),
155
+ ruleId: rule.id,
156
+ title: rule.title,
157
+ cwe: rule.cwe,
158
+ severity: rule.severity,
159
+ confidence: rule.confidence,
160
+ file: rel,
161
+ line: 1,
162
+ snippet: content.slice(0, 200),
163
+ message: rule.message,
164
+ });
165
+ }
166
+ }
167
+ return findings;
168
+ }
169
+ /** 扫描目录或单个文件。 */
170
+ export async function scanPath(options) {
171
+ const started = Date.now();
172
+ const { cwd, maxFiles, maxFileBytes, policy } = options;
173
+ const root = options.target !== undefined && options.target.trim() !== '' ? path.resolve(cwd, options.target.trim()) : cwd;
174
+ let filesScanned = 0;
175
+ let filesSkipped = 0;
176
+ const findings = [];
177
+ const relRoot = path.relative(cwd, root) || '.';
178
+ const visit = async (absolute, rel) => {
179
+ let stat;
180
+ try {
181
+ stat = await fs.stat(absolute);
182
+ }
183
+ catch {
184
+ filesSkipped += 1;
185
+ return;
186
+ }
187
+ if (stat.isDirectory()) {
188
+ if (DEFAULT_EXCLUDE_DIRS.includes(path.basename(absolute)) || policyExcludesDir(policy, rel))
189
+ return;
190
+ let entries;
191
+ try {
192
+ entries = await fs.readdir(absolute, { withFileTypes: true });
193
+ }
194
+ catch {
195
+ filesSkipped += 1;
196
+ return;
197
+ }
198
+ for (const entry of entries) {
199
+ if (filesScanned >= maxFiles)
200
+ return;
201
+ await visit(path.join(absolute, entry.name), rel === '' ? entry.name : rel + '/' + entry.name);
202
+ }
203
+ return;
204
+ }
205
+ if (!stat.isFile() || !shouldScanFile(absolute)) {
206
+ filesSkipped += 1;
207
+ return;
208
+ }
209
+ if (stat.size > maxFileBytes) {
210
+ filesSkipped += 1;
211
+ return;
212
+ }
213
+ let buffer;
214
+ try {
215
+ buffer = new Uint8Array(await fs.readFile(absolute));
216
+ }
217
+ catch {
218
+ filesSkipped += 1;
219
+ return;
220
+ }
221
+ if (fileHasBinary(buffer)) {
222
+ filesSkipped += 1;
223
+ return;
224
+ }
225
+ filesScanned += 1;
226
+ const text = new TextDecoder('utf-8', { fatal: false }).decode(buffer);
227
+ findings.push(...scanText(text, absolute, rel, policy));
228
+ };
229
+ await visit(root, relRoot);
230
+ findings.sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || a.file.localeCompare(b.file) || a.line - b.line);
231
+ return { root, filesScanned, filesSkipped, findings, durationMs: Date.now() - started };
232
+ }
233
+ function severityRank(severity) {
234
+ return severity === 'critical' ? 4 : severity === 'high' ? 3 : severity === 'medium' ? 2 : 1;
235
+ }