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/README.en.md +27 -0
- package/README.md +65 -0
- package/cordis.patch.yml +17 -0
- package/lib/config.d.ts +23 -0
- package/lib/config.js +46 -0
- package/lib/deps.d.ts +24 -0
- package/lib/deps.js +179 -0
- package/lib/diff.d.ts +29 -0
- package/lib/diff.js +88 -0
- package/lib/extra.d.ts +8 -0
- package/lib/extra.js +108 -0
- package/lib/index.d.ts +36 -0
- package/lib/index.js +51 -0
- package/lib/policy.d.ts +24 -0
- package/lib/policy.js +67 -0
- package/lib/rules.d.ts +22 -0
- package/lib/rules.js +101 -0
- package/lib/runner.d.ts +53 -0
- package/lib/runner.js +35 -0
- package/lib/scanner.d.ts +43 -0
- package/lib/scanner.js +235 -0
- package/lib/state.d.ts +60 -0
- package/lib/state.js +89 -0
- package/lib/tools.d.ts +30 -0
- package/lib/tools.js +388 -0
- package/package.json +62 -0
package/README.en.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# dsh-code-security
|
|
2
|
+
|
|
3
|
+
> Every agent code change passes a local security scan before delivery.
|
|
4
|
+
|
|
5
|
+
A DeepSeek Harness plugin for AI code security review: deterministic rule engine, git-diff incremental review, fix-verify loop, and policy gate. Methodology borrows from Codex security skills (evidence-first findings, severity ordering, supply-chain layers). Zero runtime dependencies.
|
|
6
|
+
|
|
7
|
+
## Tools
|
|
8
|
+
|
|
9
|
+
| Tool | Purpose | Write |
|
|
10
|
+
| :-- | :-- | :-- |
|
|
11
|
+
| `secure_scan` | Scan files with CWE/severity/line/snippet evidence | state |
|
|
12
|
+
| `secure_diff` | Review only added lines of git diff | state |
|
|
13
|
+
| `secure_fix_verify` | Compare with baseline: closed / remaining / fresh | state |
|
|
14
|
+
| `secure_report` | Aggregate by rule/file with gate verdict | no |
|
|
15
|
+
| `secure_export` | Export SARIF 2.1.0 / Markdown | file write approval |
|
|
16
|
+
| `secure_baseline` | Accept current findings as baseline; gate on new issues only | approval |
|
|
17
|
+
| `secure_deps` | SBOM-lite: parse dependency manifests and version-risk flags | no |
|
|
18
|
+
| `secure_policy_show` | Show .code-security.json | no |
|
|
19
|
+
| `secure_policy_set` | Replace policy JSON | approval |
|
|
20
|
+
|
|
21
|
+
40+ deterministic rules: injection, deserialization, weak crypto, secrets, dangerous config, sensitive logging, path traversal, SSRF.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
dsh plugin --profile web add dsh-code-security
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
MIT
|
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# dsh-code-security
|
|
2
|
+
|
|
3
|
+
> 让 agent 每次改完代码,先过一道本地安全扫描,修复完了才准交付。
|
|
4
|
+
|
|
5
|
+
DeepSeek Harness AI 代码安全审查插件:确定性规则引擎 + git diff 增量审查 + 修复闭环 + 策略门禁。借鉴 Codex 安全技能的方法论(漏洞分级、证据优先、供应链分层、Agent 安全),实现为可复现工具。零运行时依赖。
|
|
6
|
+
|
|
7
|
+
## 工具
|
|
8
|
+
|
|
9
|
+
| 工具 | 作用 | 写操作 |
|
|
10
|
+
| :-- | :-- | :-- |
|
|
11
|
+
| `secure_scan` | 扫描文件/目录,输出 CWE/严重度/行号/代码片段证据 | 写状态 |
|
|
12
|
+
| `secure_diff` | 只审查 git diff 新增行 | 写状态 |
|
|
13
|
+
| `secure_fix_verify` | 修复后复扫:关闭 / 仍存在 / 新引入 | 写状态 |
|
|
14
|
+
| `secure_report` | 按规则/文件聚合 + 门禁结论 | 否 |
|
|
15
|
+
| `secure_export` | 导出 SARIF 2.1.0 / Markdown 报告 | 写文件审批 |
|
|
16
|
+
| `secure_baseline` | 接受当前已知问题为基线,之后只按新增判定 | 审批门 |
|
|
17
|
+
| `secure_deps` | SBOM-lite:解析依赖清单与版本约束风险 | 否 |
|
|
18
|
+
| `secure_policy_show` | 查看 .code-security.json 策略 | 否 |
|
|
19
|
+
| `secure_policy_set` | 写入策略(排除/忽略/阈值) | 审批门 |
|
|
20
|
+
|
|
21
|
+
## 规则覆盖(40+)
|
|
22
|
+
|
|
23
|
+
- 注入:eval / exec / shell=True / SQL 拼接 / innerHTML
|
|
24
|
+
- 反序列化:pickle / yaml.load / ObjectInputStream / Marshal / unserialize
|
|
25
|
+
- 加密:弱哈希、ECB、硬编码 IV/密钥、JWT none、TLS 校验关闭
|
|
26
|
+
- 凭据:硬编码密码、令牌、私钥、高熵密钥
|
|
27
|
+
- 配置:chmod 777、Docker privileged / latest、npm audit=false、CORS *
|
|
28
|
+
- 泄露:敏感日志、错误堆栈外发、路径穿越、SSRF
|
|
29
|
+
|
|
30
|
+
发现只包含客观证据与 CWE 编号,不附修复建议——修复方案由 agent 基于证据生成。
|
|
31
|
+
|
|
32
|
+
## 安装
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
dsh plugin --profile web add dsh-code-security
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## 使用
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
secure_scan { target: src }
|
|
42
|
+
secure_diff { base: HEAD }
|
|
43
|
+
secure_fix_verify { target: src }
|
|
44
|
+
secure_baseline { reason: 历史遗留 }
|
|
45
|
+
secure_deps { target: . }
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
策略示例(`.code-security.json`):
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"version": 1,
|
|
53
|
+
"exclude": ["vendor/**", "generated/**"],
|
|
54
|
+
"ignore": [{ "ruleId": "SEC-206", "file": "test/**", "reason": "非安全敏感" }],
|
|
55
|
+
"failOn": "high"
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## 工程
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pnpm test # 构建 + 23 个测试
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# dsh-code-security 组合包补丁:把 AI 代码安全审查插件行插入 profile。
|
|
2
|
+
#
|
|
3
|
+
# 示例(用户 profile 的 cordis.patch.yml):
|
|
4
|
+
# - id: code-security
|
|
5
|
+
# name: 'dsh-code-security'
|
|
6
|
+
# config:
|
|
7
|
+
# maxFiles: 5000
|
|
8
|
+
# maxFileBytes: 2097152
|
|
9
|
+
# failOn: medium
|
|
10
|
+
# stateDir: .code-security
|
|
11
|
+
#
|
|
12
|
+
- insert:
|
|
13
|
+
- id: code-security
|
|
14
|
+
name: 'dsh-code-security'
|
|
15
|
+
config:
|
|
16
|
+
maxFiles: 5000
|
|
17
|
+
failOn: medium
|
package/lib/config.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-code-security 配置解析:扫描边界、门禁阈值与状态目录。
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-code-security/config
|
|
5
|
+
*/
|
|
6
|
+
export type Severity = 'critical' | 'high' | 'medium' | 'low';
|
|
7
|
+
export interface SecureConfig {
|
|
8
|
+
maxFiles?: number;
|
|
9
|
+
maxFileBytes?: number;
|
|
10
|
+
failOn?: Severity;
|
|
11
|
+
stateDir?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ResolvedSecureConfig {
|
|
14
|
+
maxFiles: number;
|
|
15
|
+
maxFileBytes: number;
|
|
16
|
+
failOn: Severity;
|
|
17
|
+
stateDir: string;
|
|
18
|
+
}
|
|
19
|
+
export declare const DEFAULT_EXCLUDE_DIRS: string[];
|
|
20
|
+
export declare function resolveConfig(config: SecureConfig | undefined | null, cwd?: string): ResolvedSecureConfig;
|
|
21
|
+
export declare function optionalString(args: Record<string, unknown>, key: string): string | undefined;
|
|
22
|
+
export declare function requiredString(args: Record<string, unknown>, key: string, label: string): string;
|
|
23
|
+
export declare function optionalInteger(args: Record<string, unknown>, key: string, label: string, lo: number, hi: number, fallback: number): number;
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-code-security 配置解析:扫描边界、门禁阈值与状态目录。
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-code-security/config
|
|
5
|
+
*/
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
export const DEFAULT_EXCLUDE_DIRS = ['node_modules', '.git', 'dist', 'build', 'out', 'coverage', '.next', '.cache', '.turbo', '.code-security'];
|
|
8
|
+
export function resolveConfig(config, cwd = process.cwd()) {
|
|
9
|
+
const cfg = config ?? {};
|
|
10
|
+
let maxFiles = 5000;
|
|
11
|
+
if (cfg.maxFiles !== undefined) {
|
|
12
|
+
if (typeof cfg.maxFiles !== 'number' || !Number.isFinite(cfg.maxFiles) || cfg.maxFiles <= 0)
|
|
13
|
+
throw new Error('maxFiles 必须是大于 0 的数字。');
|
|
14
|
+
maxFiles = Math.min(100000, Math.max(10, Math.round(cfg.maxFiles)));
|
|
15
|
+
}
|
|
16
|
+
let maxFileBytes = 2 * 1024 * 1024;
|
|
17
|
+
if (cfg.maxFileBytes !== undefined) {
|
|
18
|
+
if (typeof cfg.maxFileBytes !== 'number' || !Number.isFinite(cfg.maxFileBytes) || cfg.maxFileBytes <= 0)
|
|
19
|
+
throw new Error('maxFileBytes 必须是大于 0 的数字(字节)。');
|
|
20
|
+
maxFileBytes = Math.min(20 * 1024 * 1024, Math.max(64 * 1024, Math.round(cfg.maxFileBytes)));
|
|
21
|
+
}
|
|
22
|
+
const failOn = cfg.failOn === 'critical' || cfg.failOn === 'high' || cfg.failOn === 'medium' || cfg.failOn === 'low' ? cfg.failOn : 'medium';
|
|
23
|
+
const stateDir = typeof cfg.stateDir === 'string' && cfg.stateDir.trim() !== '' ? path.resolve(cwd, cfg.stateDir.trim()) : path.join(cwd, '.code-security');
|
|
24
|
+
return { maxFiles, maxFileBytes, failOn, stateDir };
|
|
25
|
+
}
|
|
26
|
+
export function optionalString(args, key) {
|
|
27
|
+
const value = args[key];
|
|
28
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
|
29
|
+
}
|
|
30
|
+
export function requiredString(args, key, label) {
|
|
31
|
+
const value = optionalString(args, key);
|
|
32
|
+
if (value === undefined)
|
|
33
|
+
throw new Error(label + '(参数 ' + key + ')为必填,请提供非空字符串。');
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
export function optionalInteger(args, key, label, lo, hi, fallback) {
|
|
37
|
+
const value = args[key];
|
|
38
|
+
if (value === undefined || value === null)
|
|
39
|
+
return fallback;
|
|
40
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
41
|
+
throw new Error(label + '(参数 ' + key + ')必须是数字。');
|
|
42
|
+
const rounded = Math.round(value);
|
|
43
|
+
if (rounded < lo || rounded > hi)
|
|
44
|
+
throw new Error(label + '(参数 ' + key + ')必须在 ' + lo + '-' + hi + ' 之间。');
|
|
45
|
+
return rounded;
|
|
46
|
+
}
|
package/lib/deps.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SBOM-lite:解析常见依赖清单,输出依赖名称、版本约束与风险标记。
|
|
3
|
+
* 只做确定性解析,不联网、不执行包管理器。
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-code-security/deps
|
|
6
|
+
*/
|
|
7
|
+
export interface DependencyEntry {
|
|
8
|
+
name: string;
|
|
9
|
+
spec: string;
|
|
10
|
+
manager: 'npm' | 'pypi' | 'go' | 'cargo' | 'composer' | 'ruby';
|
|
11
|
+
file: string;
|
|
12
|
+
kind: 'runtime' | 'dev' | 'peer' | 'optional' | 'unknown';
|
|
13
|
+
risk: 'exact' | 'range' | 'unpinned' | 'git' | 'local' | 'wildcard';
|
|
14
|
+
note: string;
|
|
15
|
+
}
|
|
16
|
+
export interface DepsScanResult {
|
|
17
|
+
root: string;
|
|
18
|
+
filesScanned: number;
|
|
19
|
+
dependencies: DependencyEntry[];
|
|
20
|
+
riskyCount: number;
|
|
21
|
+
warnings: string[];
|
|
22
|
+
}
|
|
23
|
+
/** 扫描依赖清单,返回 SBOM-lite 结果。 */
|
|
24
|
+
export declare function scanDeps(root: string): Promise<DepsScanResult>;
|
package/lib/deps.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SBOM-lite:解析常见依赖清单,输出依赖名称、版本约束与风险标记。
|
|
3
|
+
* 只做确定性解析,不联网、不执行包管理器。
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-code-security/deps
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { DEFAULT_EXCLUDE_DIRS } from './config.js';
|
|
10
|
+
const MANIFESTS = ['package.json', 'requirements.txt', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'composer.json', 'Gemfile'];
|
|
11
|
+
function asRecord(value) {
|
|
12
|
+
return typeof value === 'object' && value !== null ? value : {};
|
|
13
|
+
}
|
|
14
|
+
function riskOfSpec(spec) {
|
|
15
|
+
const s = spec.trim();
|
|
16
|
+
if (s === '')
|
|
17
|
+
return { risk: 'unpinned', note: '未声明版本约束' };
|
|
18
|
+
if (s === '*' || /^latest$/i.test(s) || /^x$/i.test(s))
|
|
19
|
+
return { risk: 'wildcard', note: '版本约束为通配' };
|
|
20
|
+
if (/^(?:https?|git|git+|github:)/i.test(s))
|
|
21
|
+
return { risk: /#([0-9a-f]{7,40})\b/i.test(s) ? 'exact' : 'git', note: /#/.test(s) ? 'git 源已带提交锁定' : 'git 源未锁定到提交' };
|
|
22
|
+
if (/^(?:file|link):/i.test(s))
|
|
23
|
+
return { risk: 'local', note: '本地/链接依赖,发布时会失配' };
|
|
24
|
+
if (/^[0-9]+(?:\.[A-Za-z0-9.*-]+)+$/.test(s) || /^==[0-9]/.test(s))
|
|
25
|
+
return { risk: 'exact', note: '' };
|
|
26
|
+
if (/[<>=~^]/.test(s) || s.includes('||'))
|
|
27
|
+
return { risk: 'range', note: '' };
|
|
28
|
+
return { risk: 'unpinned', note: '无法识别版本约束' };
|
|
29
|
+
}
|
|
30
|
+
function pushDeps(list, entries, manager, file, kind) {
|
|
31
|
+
for (const [name, rawSpec] of Object.entries(entries)) {
|
|
32
|
+
const spec = typeof rawSpec === 'string' ? rawSpec : '';
|
|
33
|
+
const { risk, note } = riskOfSpec(spec);
|
|
34
|
+
list.push({ name, spec, manager, file, kind, risk, note });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function parsePackageJson(text, file) {
|
|
38
|
+
try {
|
|
39
|
+
const pkg = JSON.parse(text);
|
|
40
|
+
const list = [];
|
|
41
|
+
pushDeps(list, asRecord(pkg.dependencies), 'npm', file, 'runtime');
|
|
42
|
+
pushDeps(list, asRecord(pkg.devDependencies), 'npm', file, 'dev');
|
|
43
|
+
pushDeps(list, asRecord(pkg.peerDependencies), 'npm', file, 'peer');
|
|
44
|
+
pushDeps(list, asRecord(pkg.optionalDependencies), 'npm', file, 'optional');
|
|
45
|
+
return list;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function parseRequirements(text, file) {
|
|
52
|
+
const list = [];
|
|
53
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
54
|
+
const line = raw.trim();
|
|
55
|
+
if (line === '' || line.startsWith('#') || line.startsWith('-'))
|
|
56
|
+
continue;
|
|
57
|
+
const m = /^([A-Za-z0-9_.\-]+)(.*)$/.exec(line);
|
|
58
|
+
if (m === null)
|
|
59
|
+
continue;
|
|
60
|
+
const spec = m[2]?.trim() ?? '';
|
|
61
|
+
const { risk, note } = riskOfSpec(spec);
|
|
62
|
+
list.push({ name: m[1], spec, manager: 'pypi', file, kind: 'runtime', risk, note });
|
|
63
|
+
}
|
|
64
|
+
return list;
|
|
65
|
+
}
|
|
66
|
+
function parsePyproject(text, file) {
|
|
67
|
+
const list = [];
|
|
68
|
+
const re = /^\s*([A-Za-z0-9_.\-]+)\s*(?:[<>=!~^]+[^\s,]+)?/gm;
|
|
69
|
+
for (const section of text.matchAll(/\[project\.(?:optional-)?dependencies\][\s\S]*?(?=\n\[|$)/g)) {
|
|
70
|
+
for (const line of section[0].split(/\r?\n/)) {
|
|
71
|
+
const trimmed = line.trim();
|
|
72
|
+
if (trimmed === '' || trimmed.startsWith('['))
|
|
73
|
+
continue;
|
|
74
|
+
const m = /^([A-Za-z0-9_.\-]+)\s*(.*)$/.exec(trimmed.replace(/^"|"$/g, ''));
|
|
75
|
+
if (m === null)
|
|
76
|
+
continue;
|
|
77
|
+
const spec = m[2]?.trim() ?? '';
|
|
78
|
+
const { risk, note } = riskOfSpec(spec);
|
|
79
|
+
list.push({ name: m[1], spec, manager: 'pypi', file, kind: 'runtime', risk, note });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return list;
|
|
83
|
+
}
|
|
84
|
+
function parseGoMod(text, file) {
|
|
85
|
+
const list = [];
|
|
86
|
+
const re = /^\s*([A-Za-z0-9_.\/\-]+)\s+(v[0-9][^\s]*)/gm;
|
|
87
|
+
for (const m of text.matchAll(re)) {
|
|
88
|
+
list.push({ name: m[1], spec: m[2], manager: 'go', file, kind: 'runtime', risk: 'exact', note: '' });
|
|
89
|
+
}
|
|
90
|
+
return list;
|
|
91
|
+
}
|
|
92
|
+
function parseCargo(text, file) {
|
|
93
|
+
const list = [];
|
|
94
|
+
for (const line of text.split(/\r?\n/)) {
|
|
95
|
+
const m = /^\s*([A-Za-z0-9_.\-]+)\s*=\s*(.+)$/.exec(line);
|
|
96
|
+
if (m === null)
|
|
97
|
+
continue;
|
|
98
|
+
const spec = m[2].trim().replace(/^["']|["']$/g, '');
|
|
99
|
+
const isGit = /git\s*=|branch\s*=|rev\s*=/.test(spec);
|
|
100
|
+
const risk = isGit ? (spec.includes('rev') ? 'exact' : 'git') : 'range';
|
|
101
|
+
list.push({ name: m[1], spec, manager: 'cargo', file, kind: 'runtime', risk, note: isGit && !spec.includes('rev') ? 'git 源未锁定 rev' : '' });
|
|
102
|
+
}
|
|
103
|
+
return list;
|
|
104
|
+
}
|
|
105
|
+
function parseComposer(text, file) {
|
|
106
|
+
const list = [];
|
|
107
|
+
try {
|
|
108
|
+
const pkg = JSON.parse(text);
|
|
109
|
+
for (const [key, entries] of [['require', 'runtime'], ['require-dev', 'dev']]) {
|
|
110
|
+
pushDeps(list, asRecord(pkg[key]), 'composer', file, entries);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// ignore malformed
|
|
115
|
+
}
|
|
116
|
+
return list;
|
|
117
|
+
}
|
|
118
|
+
function parseGemfile(text, file) {
|
|
119
|
+
const list = [];
|
|
120
|
+
for (const m of text.matchAll(/^\s*gem\s+['"]([^'"]+)['"]\s*(?:,\s*['"]([^'"]*)['"]\s*)?/gm)) {
|
|
121
|
+
const spec = m[2] ?? '';
|
|
122
|
+
const { risk, note } = riskOfSpec(spec);
|
|
123
|
+
list.push({ name: m[1], spec, manager: 'ruby', file, kind: 'runtime', risk, note });
|
|
124
|
+
}
|
|
125
|
+
return list;
|
|
126
|
+
}
|
|
127
|
+
/** 扫描依赖清单,返回 SBOM-lite 结果。 */
|
|
128
|
+
export async function scanDeps(root) {
|
|
129
|
+
const dependencies = [];
|
|
130
|
+
const warnings = [];
|
|
131
|
+
let filesScanned = 0;
|
|
132
|
+
const visit = async (absolute, rel) => {
|
|
133
|
+
let stat;
|
|
134
|
+
try {
|
|
135
|
+
stat = await fs.stat(absolute);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (stat.isDirectory()) {
|
|
141
|
+
if (DEFAULT_EXCLUDE_DIRS.includes(path.basename(absolute)))
|
|
142
|
+
return;
|
|
143
|
+
const entries = await fs.readdir(absolute, { withFileTypes: true });
|
|
144
|
+
for (const entry of entries)
|
|
145
|
+
await visit(path.join(absolute, entry.name), rel === '' ? entry.name : rel + '/' + entry.name);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const base = path.basename(absolute);
|
|
149
|
+
if (!MANIFESTS.includes(base))
|
|
150
|
+
return;
|
|
151
|
+
filesScanned += 1;
|
|
152
|
+
let text = '';
|
|
153
|
+
try {
|
|
154
|
+
text = await fs.readFile(absolute, 'utf8');
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
warnings.push('无法读取 ' + rel);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (base === 'package.json')
|
|
161
|
+
dependencies.push(...parsePackageJson(text, rel));
|
|
162
|
+
else if (base === 'requirements.txt')
|
|
163
|
+
dependencies.push(...parseRequirements(text, rel));
|
|
164
|
+
else if (base === 'pyproject.toml')
|
|
165
|
+
dependencies.push(...parsePyproject(text, rel));
|
|
166
|
+
else if (base === 'go.mod')
|
|
167
|
+
dependencies.push(...parseGoMod(text, rel));
|
|
168
|
+
else if (base === 'Cargo.toml')
|
|
169
|
+
dependencies.push(...parseCargo(text, rel));
|
|
170
|
+
else if (base === 'composer.json')
|
|
171
|
+
dependencies.push(...parseComposer(text, rel));
|
|
172
|
+
else if (base === 'Gemfile')
|
|
173
|
+
dependencies.push(...parseGemfile(text, rel));
|
|
174
|
+
};
|
|
175
|
+
await visit(root, '');
|
|
176
|
+
dependencies.sort((a, b) => a.file.localeCompare(b.file) || a.name.localeCompare(b.name));
|
|
177
|
+
const risky = dependencies.filter((item) => item.risk !== 'exact' && item.risk !== 'range');
|
|
178
|
+
return { root, filesScanned, dependencies, riskyCount: risky.length, warnings };
|
|
179
|
+
}
|
package/lib/diff.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git diff 增量扫描:只解析 unified diff 的新增行并映射到新文件行号。
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-code-security/diff
|
|
5
|
+
*/
|
|
6
|
+
import type { Finding } from './scanner.js';
|
|
7
|
+
import type { SecurePolicy } from './policy.js';
|
|
8
|
+
import type { ProcessRunner } from './runner.js';
|
|
9
|
+
export interface DiffLine {
|
|
10
|
+
line: number;
|
|
11
|
+
text: string;
|
|
12
|
+
}
|
|
13
|
+
export interface DiffFile {
|
|
14
|
+
file: string;
|
|
15
|
+
added: DiffLine[];
|
|
16
|
+
}
|
|
17
|
+
export interface DiffScanResult {
|
|
18
|
+
base: string;
|
|
19
|
+
filesChanged: number;
|
|
20
|
+
addedLines: number;
|
|
21
|
+
findings: Finding[];
|
|
22
|
+
diff: string;
|
|
23
|
+
}
|
|
24
|
+
/** 运行 git diff(默认 HEAD,工作区未提交改动)。 */
|
|
25
|
+
export declare function runGitDiff(runner: ProcessRunner, cwd: string, base: string, target?: string, timeoutMs?: number, staged?: boolean): Promise<string>;
|
|
26
|
+
/** 解析 unified diff,返回每个文件的新增行与新行号。 */
|
|
27
|
+
export declare function parseDiff(diff: string, cwd: string): DiffFile[];
|
|
28
|
+
/** 对 diff 新增行执行规则扫描。 */
|
|
29
|
+
export declare function scanDiff(diff: string, cwd: string, policy: SecurePolicy): DiffScanResult;
|
package/lib/diff.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git diff 增量扫描:只解析 unified diff 的新增行并映射到新文件行号。
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-code-security/diff
|
|
5
|
+
*/
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { scanLineSet } from './scanner.js';
|
|
9
|
+
/** 运行 git diff(默认 HEAD,工作区未提交改动)。 */
|
|
10
|
+
export async function runGitDiff(runner, cwd, base, target, timeoutMs = 30000, staged = false) {
|
|
11
|
+
const argv = ['git', 'diff'];
|
|
12
|
+
if (staged)
|
|
13
|
+
argv.push('--cached');
|
|
14
|
+
argv.push('--no-ext-diff', '--unified=0', base);
|
|
15
|
+
if (target !== undefined && target.trim() !== '')
|
|
16
|
+
argv.push('--', target.trim());
|
|
17
|
+
const result = await runner.run(argv, { timeoutMs });
|
|
18
|
+
if (result.exitCode !== 0 && result.exitCode !== 1) {
|
|
19
|
+
throw new Error('git diff 失败(退出码 ' + String(result.exitCode) + '):' + result.stderr.trim().split(/\r?\n/).slice(-4).join(' | '));
|
|
20
|
+
}
|
|
21
|
+
if (result.stdout.trim() === '')
|
|
22
|
+
return '';
|
|
23
|
+
return result.stdout;
|
|
24
|
+
}
|
|
25
|
+
function isNewFileHeader(line) {
|
|
26
|
+
const m = /^\+\+\+ b\/(.*)$/.exec(line);
|
|
27
|
+
if (m === null)
|
|
28
|
+
return null;
|
|
29
|
+
const file = m[1].replace(/\t.*$/, '');
|
|
30
|
+
if (file === '' || file === '/dev/null')
|
|
31
|
+
return null;
|
|
32
|
+
return file;
|
|
33
|
+
}
|
|
34
|
+
/** 解析 unified diff,返回每个文件的新增行与新行号。 */
|
|
35
|
+
export function parseDiff(diff, cwd) {
|
|
36
|
+
const files = [];
|
|
37
|
+
let current = null;
|
|
38
|
+
let newLine = 0;
|
|
39
|
+
for (const raw of diff.split(/\r?\n/)) {
|
|
40
|
+
const file = isNewFileHeader(raw);
|
|
41
|
+
if (file !== null) {
|
|
42
|
+
current = { file: file.replace(/\\/g, '/'), added: [] };
|
|
43
|
+
files.push(current);
|
|
44
|
+
newLine = 0;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const hunk = /^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/.exec(raw);
|
|
48
|
+
if (hunk !== null && current !== null) {
|
|
49
|
+
newLine = Number(hunk[1]);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (current === null)
|
|
53
|
+
continue;
|
|
54
|
+
if (raw.startsWith('+++') || raw.startsWith('---'))
|
|
55
|
+
continue;
|
|
56
|
+
if (raw.startsWith('+')) {
|
|
57
|
+
current.added.push({ line: newLine, text: raw.slice(1) });
|
|
58
|
+
newLine += 1;
|
|
59
|
+
}
|
|
60
|
+
else if (raw.startsWith('-')) {
|
|
61
|
+
// removed line: new line number stays
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
newLine += 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return files;
|
|
68
|
+
}
|
|
69
|
+
function makeDiffId(ruleId, file, line, snippet) {
|
|
70
|
+
const hash = createHash('sha256').update('diff|' + ruleId + '|' + file + '|' + line + '|' + snippet.slice(0, 80)).digest('hex').slice(0, 10);
|
|
71
|
+
return 'SRD-' + hash;
|
|
72
|
+
}
|
|
73
|
+
/** 对 diff 新增行执行规则扫描。 */
|
|
74
|
+
export function scanDiff(diff, cwd, policy) {
|
|
75
|
+
const parsed = parseDiff(diff, cwd);
|
|
76
|
+
const findings = [];
|
|
77
|
+
let addedLines = 0;
|
|
78
|
+
for (const file of parsed) {
|
|
79
|
+
addedLines += file.added.length;
|
|
80
|
+
const rel = path.isAbsolute(file.file) ? path.relative(cwd, file.file) : file.file;
|
|
81
|
+
findings.push(...scanLineSet(file.added, rel, policy).map((finding) => ({ ...finding, id: makeDiffId(finding.ruleId, rel, finding.line, finding.snippet) })));
|
|
82
|
+
}
|
|
83
|
+
findings.sort((a, b) => severityOrder(b.severity) - severityOrder(a.severity) || a.file.localeCompare(b.file) || a.line - b.line);
|
|
84
|
+
return { base: 'HEAD', filesChanged: parsed.length, addedLines, findings, diff };
|
|
85
|
+
}
|
|
86
|
+
function severityOrder(severity) {
|
|
87
|
+
return severity === 'critical' ? 4 : severity === 'high' ? 3 : severity === 'medium' ? 2 : 1;
|
|
88
|
+
}
|
package/lib/extra.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扩展工具:secure_baseline(基线接受)与 secure_deps(SBOM-lite)。
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-code-security/extra
|
|
5
|
+
*/
|
|
6
|
+
import { type ResolvedSecureConfig } from './config.js';
|
|
7
|
+
import type { SecureToolDefinition } from './tools.js';
|
|
8
|
+
export declare function buildExtraTools(cfg: ResolvedSecureConfig, cwd: string): SecureToolDefinition[];
|
package/lib/extra.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扩展工具:secure_baseline(基线接受)与 secure_deps(SBOM-lite)。
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-code-security/extra
|
|
5
|
+
*/
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { optionalString, requiredString } from './config.js';
|
|
8
|
+
import { scanDeps } from './deps.js';
|
|
9
|
+
import { loadState, saveBaseline } from './state.js';
|
|
10
|
+
function compileParameters(spec) {
|
|
11
|
+
const properties = {};
|
|
12
|
+
const required = [];
|
|
13
|
+
for (const [key, prop] of Object.entries(spec)) {
|
|
14
|
+
if (prop?.required === true)
|
|
15
|
+
required.push(key);
|
|
16
|
+
const node = {};
|
|
17
|
+
if (typeof prop?.type === 'string')
|
|
18
|
+
node.type = prop.type;
|
|
19
|
+
if (typeof prop?.description === 'string')
|
|
20
|
+
node.description = prop.description;
|
|
21
|
+
properties[key] = node;
|
|
22
|
+
}
|
|
23
|
+
return { type: 'object', properties, ...(required.length > 0 ? { required } : {}) };
|
|
24
|
+
}
|
|
25
|
+
export function buildExtraTools(cfg, cwd) {
|
|
26
|
+
const stateDir = cfg.stateDir;
|
|
27
|
+
const secureBaseline = {
|
|
28
|
+
name: 'secure_baseline',
|
|
29
|
+
description: '把最近一次扫描的全部问题标记为已接受基线。之后 secure_scan / secure_diff / secure_report 只按基线外的新增问题判定门禁。写操作需要审批。',
|
|
30
|
+
parameters: compileParameters({
|
|
31
|
+
reason: { type: 'string', required: true, description: '接受这些已知问题的原因(会写入状态,例如:历史遗留,计划在 v1.2 修复)。' },
|
|
32
|
+
}),
|
|
33
|
+
output: {
|
|
34
|
+
schema: { type: 'object', properties: { file: { type: 'string' }, acceptedCount: { type: 'integer' }, baseline: { type: 'object', additionalProperties: true } }, additionalProperties: true },
|
|
35
|
+
render: (_args, value) => {
|
|
36
|
+
const rec = (value ?? {});
|
|
37
|
+
return [{ type: 'text', text: '已接受基线:' + String(rec.acceptedCount ?? 0) + ' 个已知问题(' + String(rec.file ?? '') + ')。' }];
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
async execute(rawArgs) {
|
|
41
|
+
const args = (rawArgs ?? {});
|
|
42
|
+
const reason = requiredString(args, 'reason', '接受原因');
|
|
43
|
+
const state = await loadState(stateDir);
|
|
44
|
+
if (state.last === null)
|
|
45
|
+
throw new Error('尚无扫描结果,请先执行 secure_scan 或 secure_diff。');
|
|
46
|
+
const baseline = {
|
|
47
|
+
time: new Date().toISOString(),
|
|
48
|
+
target: state.last.target,
|
|
49
|
+
reason,
|
|
50
|
+
findings: state.last.findings,
|
|
51
|
+
fingerprints: state.last.fingerprints,
|
|
52
|
+
counts: state.last.counts,
|
|
53
|
+
};
|
|
54
|
+
const file = await saveBaseline(stateDir, baseline);
|
|
55
|
+
return { file, acceptedCount: baseline.findings.length, baseline };
|
|
56
|
+
},
|
|
57
|
+
timeoutMs: 10000,
|
|
58
|
+
};
|
|
59
|
+
const secureDeps = {
|
|
60
|
+
name: 'secure_deps',
|
|
61
|
+
description: 'SBOM-lite:解析项目依赖清单(package.json / requirements.txt / pyproject.toml / go.mod / Cargo.toml / composer.json / Gemfile),列出依赖名称、版本约束与风险标记(wildcard/git/local/unpinned)。不联网。',
|
|
62
|
+
parameters: compileParameters({
|
|
63
|
+
target: { type: 'string', description: '扫描根目录(可选,默认当前工作区)。' },
|
|
64
|
+
}),
|
|
65
|
+
output: {
|
|
66
|
+
schema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
69
|
+
root: { type: 'string' }, filesScanned: { type: 'integer' }, riskyCount: { type: 'integer' },
|
|
70
|
+
dependencies: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
71
|
+
warnings: { type: 'array', items: { type: 'string' } },
|
|
72
|
+
},
|
|
73
|
+
additionalProperties: true,
|
|
74
|
+
},
|
|
75
|
+
render: (_args, value) => {
|
|
76
|
+
const rec = (value ?? {});
|
|
77
|
+
const deps = Array.isArray(rec.dependencies) ? rec.dependencies : [];
|
|
78
|
+
const lines = ['依赖清单扫描:' + deps.length + ' 个依赖,风险项 ' + String(rec.riskyCount ?? 0) + ' 个。'];
|
|
79
|
+
for (const dep of deps) {
|
|
80
|
+
const row = (dep ?? {});
|
|
81
|
+
if (row.risk !== 'exact' && row.risk !== 'range')
|
|
82
|
+
lines.push('- ' + String(row.name ?? '') + ' [' + String(row.risk ?? '') + '] ' + String(row.file ?? ''));
|
|
83
|
+
}
|
|
84
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
async execute(rawArgs) {
|
|
88
|
+
const args = (rawArgs ?? {});
|
|
89
|
+
const target = optionalString(args, 'target');
|
|
90
|
+
const root = path.resolve(cwd, target ?? '.');
|
|
91
|
+
return scanDeps(root);
|
|
92
|
+
},
|
|
93
|
+
timeoutMs: 30000,
|
|
94
|
+
};
|
|
95
|
+
secureBaseline.gate = async (exec, next) => {
|
|
96
|
+
const record = (typeof exec === 'object' && exec !== null ? exec : {});
|
|
97
|
+
const approval = record.approval;
|
|
98
|
+
if (approval === undefined)
|
|
99
|
+
return { kind: 'deny', reason: 'secure_baseline 需要确认,但当前环境没有审批通道。' };
|
|
100
|
+
const outcome = await approval.request({ reason: '把当前全部安全问题接受为基线' });
|
|
101
|
+
if (outcome === 'allowed-once')
|
|
102
|
+
return next();
|
|
103
|
+
if (outcome === 'cancelled')
|
|
104
|
+
return { kind: 'deny', reason: '基线接受被取消,未执行。' };
|
|
105
|
+
return { kind: 'deny', reason: '基线接受未获批准。' };
|
|
106
|
+
};
|
|
107
|
+
return [secureBaseline, secureDeps];
|
|
108
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
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 { type SecureConfig } from './config.js';
|
|
11
|
+
import { type SubprocessSpawnLike } from './runner.js';
|
|
12
|
+
import { type SecureToolDefinition } from './tools.js';
|
|
13
|
+
export declare const inject: string[];
|
|
14
|
+
export interface SecurePluginContext {
|
|
15
|
+
subprocess: {
|
|
16
|
+
spawn: SubprocessSpawnLike;
|
|
17
|
+
};
|
|
18
|
+
tools: {
|
|
19
|
+
register(definition: SecureToolDefinition, options?: {
|
|
20
|
+
prepend?: boolean;
|
|
21
|
+
}): () => void;
|
|
22
|
+
};
|
|
23
|
+
get?(name: 'approval'): unknown;
|
|
24
|
+
on?(event: string, listener: () => void): () => void;
|
|
25
|
+
}
|
|
26
|
+
export declare function apply(ctx: SecurePluginContext, config?: SecureConfig | null): void;
|
|
27
|
+
export * from './config.js';
|
|
28
|
+
export * from './deps.js';
|
|
29
|
+
export * from './diff.js';
|
|
30
|
+
export * from './policy.js';
|
|
31
|
+
export * from './rules.js';
|
|
32
|
+
export * from './runner.js';
|
|
33
|
+
export * from './scanner.js';
|
|
34
|
+
export * from './state.js';
|
|
35
|
+
export * from './extra.js';
|
|
36
|
+
export * from './tools.js';
|