security-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/defaults/evidence-map.json +126 -0
- package/defaults/security-policy.json +93 -0
- package/dist/ci/pr-gate.js +17 -0
- package/dist/cli/index.js +140 -0
- package/dist/cli/install.js +161 -0
- package/dist/gate/checks/ai.js +39 -0
- package/dist/gate/checks/api.js +46 -0
- package/dist/gate/checks/dependencies.js +39 -0
- package/dist/gate/checks/infra.js +38 -0
- package/dist/gate/checks/mobile-android.js +35 -0
- package/dist/gate/checks/mobile-ios.js +23 -0
- package/dist/gate/checks/required-artifacts.js +25 -0
- package/dist/gate/checks/secrets.js +31 -0
- package/dist/gate/checks/web-nextjs.js +76 -0
- package/dist/gate/diff.js +11 -0
- package/dist/gate/findings.js +11 -0
- package/dist/gate/policy.js +68 -0
- package/dist/gate/result.js +1 -0
- package/dist/mcp/server.js +463 -0
- package/dist/repo/fs.js +9 -0
- package/dist/repo/search.js +41 -0
- package/package.json +76 -0
- package/prompts/SECURITY_PROMPT.md +931 -0
- package/skills/security-review/SKILL.md +922 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* security-mcp install command
|
|
3
|
+
*
|
|
4
|
+
* Auto-detects installed editors and writes MCP server config + Claude Code skill.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from "fs";
|
|
7
|
+
import { dirname, join, resolve } from "path";
|
|
8
|
+
import { homedir, platform } from "os";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const PKG_ROOT = resolve(__dirname, "../..");
|
|
12
|
+
const MCP_ENTRY = {
|
|
13
|
+
command: "npx",
|
|
14
|
+
args: ["-y", "security-mcp", "serve"]
|
|
15
|
+
};
|
|
16
|
+
function resolveHome(p) {
|
|
17
|
+
return p.replace(/^~/, homedir());
|
|
18
|
+
}
|
|
19
|
+
function getVsCodeSettingsPath() {
|
|
20
|
+
const os = platform();
|
|
21
|
+
if (os === "win32") {
|
|
22
|
+
return join(process.env["APPDATA"] ?? "", "Code", "User", "settings.json");
|
|
23
|
+
}
|
|
24
|
+
if (os === "darwin") {
|
|
25
|
+
return join(homedir(), "Library", "Application Support", "Code", "User", "settings.json");
|
|
26
|
+
}
|
|
27
|
+
return join(homedir(), ".config", "Code", "User", "settings.json");
|
|
28
|
+
}
|
|
29
|
+
function getEditorTargets(opts) {
|
|
30
|
+
const claudeCodePath = resolveHome("~/.claude/settings.json");
|
|
31
|
+
const cursorGlobalPath = resolveHome("~/.cursor/mcp.json");
|
|
32
|
+
const cursorLocalPath = ".cursor/mcp.json";
|
|
33
|
+
const vscodePath = getVsCodeSettingsPath();
|
|
34
|
+
const all = [
|
|
35
|
+
{
|
|
36
|
+
name: "Claude Code",
|
|
37
|
+
configPath: claudeCodePath,
|
|
38
|
+
type: "mcp-servers-json",
|
|
39
|
+
detected: existsSync(resolveHome("~/.claude"))
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "Cursor (global)",
|
|
43
|
+
configPath: cursorGlobalPath,
|
|
44
|
+
type: "mcp-servers-json",
|
|
45
|
+
detected: existsSync(resolveHome("~/.cursor"))
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "Cursor (workspace)",
|
|
49
|
+
configPath: cursorLocalPath,
|
|
50
|
+
type: "mcp-servers-json",
|
|
51
|
+
detected: existsSync(".cursor")
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "VS Code",
|
|
55
|
+
configPath: vscodePath,
|
|
56
|
+
type: "vscode-settings",
|
|
57
|
+
detected: existsSync(vscodePath)
|
|
58
|
+
}
|
|
59
|
+
];
|
|
60
|
+
if (opts.all) {
|
|
61
|
+
return all.filter((t) => t.detected);
|
|
62
|
+
}
|
|
63
|
+
return all.filter((t) => {
|
|
64
|
+
if (opts.claudeCode && t.name.startsWith("Claude Code"))
|
|
65
|
+
return true;
|
|
66
|
+
if (opts.cursor && t.name.startsWith("Cursor"))
|
|
67
|
+
return true;
|
|
68
|
+
if (opts.vscode && t.name === "VS Code")
|
|
69
|
+
return true;
|
|
70
|
+
return false;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function readJsonSafe(filePath) {
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function writeMcpServersJson(configPath, dryRun) {
|
|
82
|
+
const existing = readJsonSafe(configPath);
|
|
83
|
+
const servers = existing["mcpServers"] ?? {};
|
|
84
|
+
servers["security-mcp"] = MCP_ENTRY;
|
|
85
|
+
existing["mcpServers"] = servers;
|
|
86
|
+
const content = JSON.stringify(existing, null, 2) + "\n";
|
|
87
|
+
if (!dryRun) {
|
|
88
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
89
|
+
writeFileSync(configPath, content, "utf-8");
|
|
90
|
+
}
|
|
91
|
+
return configPath;
|
|
92
|
+
}
|
|
93
|
+
function writeVsCodeSettings(configPath, dryRun) {
|
|
94
|
+
const existing = readJsonSafe(configPath);
|
|
95
|
+
const servers = existing["mcp.servers"] ?? {};
|
|
96
|
+
servers["security-mcp"] = MCP_ENTRY;
|
|
97
|
+
existing["mcp.servers"] = servers;
|
|
98
|
+
const content = JSON.stringify(existing, null, 2) + "\n";
|
|
99
|
+
if (!dryRun) {
|
|
100
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
101
|
+
writeFileSync(configPath, content, "utf-8");
|
|
102
|
+
}
|
|
103
|
+
return configPath;
|
|
104
|
+
}
|
|
105
|
+
function installSkill(dryRun) {
|
|
106
|
+
const skillSrc = join(PKG_ROOT, "skills", "security-review", "SKILL.md");
|
|
107
|
+
const skillDest = resolveHome("~/.claude/skills/security-review/SKILL.md");
|
|
108
|
+
if (!existsSync(skillSrc)) {
|
|
109
|
+
process.stdout.write(" [skip] skills/security-review/SKILL.md not found in package\n");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!dryRun) {
|
|
113
|
+
mkdirSync(dirname(skillDest), { recursive: true });
|
|
114
|
+
copyFileSync(skillSrc, skillDest);
|
|
115
|
+
}
|
|
116
|
+
process.stdout.write(` ${dryRun ? "[dry-run] would copy" : "installed"} skill: ${skillDest}\n`);
|
|
117
|
+
}
|
|
118
|
+
export async function runInstall(opts) {
|
|
119
|
+
const dryRun = opts.dryRun;
|
|
120
|
+
process.stdout.write(`\nsecurity-mcp installer${dryRun ? " (dry-run)" : ""}\n`);
|
|
121
|
+
process.stdout.write("=".repeat(40) + "\n\n");
|
|
122
|
+
const targets = getEditorTargets(opts);
|
|
123
|
+
if (targets.length === 0) {
|
|
124
|
+
process.stdout.write("No supported editors detected automatically.\n" +
|
|
125
|
+
"Run with --claude-code, --cursor, or --vscode to target a specific editor.\n" +
|
|
126
|
+
'Or add the config manually (run "npx security-mcp config" for the snippet).\n\n');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
for (const target of targets) {
|
|
130
|
+
process.stdout.write(`Installing for ${target.name}...\n`);
|
|
131
|
+
try {
|
|
132
|
+
let written;
|
|
133
|
+
if (target.type === "vscode-settings") {
|
|
134
|
+
written = writeVsCodeSettings(target.configPath, dryRun);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
written = writeMcpServersJson(target.configPath, dryRun);
|
|
138
|
+
}
|
|
139
|
+
process.stdout.write(` ${dryRun ? "[dry-run] would update" : "updated"}: ${written}\n`);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
process.stdout.write(` [error] ${err instanceof Error ? err.message : String(err)}\n`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Install Claude Code skill if Claude Code is in scope
|
|
146
|
+
const hasClaudeCode = targets.some((t) => t.name.startsWith("Claude Code"));
|
|
147
|
+
if (hasClaudeCode || opts.all) {
|
|
148
|
+
process.stdout.write("\nInstalling Claude Code skill...\n");
|
|
149
|
+
installSkill(dryRun);
|
|
150
|
+
}
|
|
151
|
+
process.stdout.write("\n");
|
|
152
|
+
process.stdout.write(dryRun
|
|
153
|
+
? "Dry-run complete. Re-run without --dry-run to apply.\n"
|
|
154
|
+
: "Done! Restart your editor to activate the security-mcp server.\n");
|
|
155
|
+
process.stdout.write("\nNext steps:\n");
|
|
156
|
+
process.stdout.write(" 1. Restart your editor.\n");
|
|
157
|
+
process.stdout.write(' 2. In Claude Code, type /security-review to invoke the security skill.\n');
|
|
158
|
+
process.stdout.write(' 3. Ask your AI: "Run security.run_pr_gate" to check your current diff.\n');
|
|
159
|
+
process.stdout.write(" 4. Copy defaults/security-policy.json to .mcp/policies/security-policy.json\n");
|
|
160
|
+
process.stdout.write(" and customize it for your project.\n\n");
|
|
161
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { searchRepo } from "../../repo/search.js";
|
|
2
|
+
export async function checkAi(_) {
|
|
3
|
+
const findings = [];
|
|
4
|
+
const schemaEnforcement = await searchRepo({
|
|
5
|
+
query: String.raw `zod\.object\(|outputSchema|json_schema|JSON schema`,
|
|
6
|
+
isRegex: true,
|
|
7
|
+
maxMatches: 200
|
|
8
|
+
});
|
|
9
|
+
const toolUse = await searchRepo({ query: "tool|function_call|tools:", isRegex: true, maxMatches: 200 });
|
|
10
|
+
if (toolUse.length > 0 && schemaEnforcement.length === 0) {
|
|
11
|
+
findings.push({
|
|
12
|
+
id: "AI_OUTPUT_BOUNDS_MISSING",
|
|
13
|
+
title: "AI/tooling present but bounded output (schema validation) not detected",
|
|
14
|
+
severity: "HIGH",
|
|
15
|
+
requiredActions: [
|
|
16
|
+
"Enforce bounded outputs via JSON schema validation for every AI response used by code.",
|
|
17
|
+
"Add prompt-injection defenses: input sanitization, tool allowlists, deny-by-default tool router, and sensitive data redaction."
|
|
18
|
+
]
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
const systemPromptLeaks = await searchRepo({
|
|
22
|
+
query: "system prompt|developer message|ignore previous|prompt injection",
|
|
23
|
+
isRegex: true,
|
|
24
|
+
maxMatches: 200
|
|
25
|
+
});
|
|
26
|
+
if (systemPromptLeaks.length > 0) {
|
|
27
|
+
findings.push({
|
|
28
|
+
id: "AI_INJECTION_CUES",
|
|
29
|
+
title: "Potential prompt injection cues detected. Requires explicit mitigations and tests.",
|
|
30
|
+
severity: "MEDIUM",
|
|
31
|
+
evidence: systemPromptLeaks.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
32
|
+
requiredActions: [
|
|
33
|
+
"Add multi-layer prompt-injection protection: instruction hierarchy enforcement, content isolation, tool gating, and output validation.",
|
|
34
|
+
"Add a red-team test harness with injection payloads and exfil attempts."
|
|
35
|
+
]
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return findings;
|
|
39
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { searchRepo } from "../../repo/search.js";
|
|
2
|
+
export async function checkApi(_) {
|
|
3
|
+
const findings = [];
|
|
4
|
+
const zodHits = await searchRepo({ query: "zod|valibot|yup|joi", isRegex: true, maxMatches: 200 });
|
|
5
|
+
if (zodHits.length === 0) {
|
|
6
|
+
findings.push({
|
|
7
|
+
id: "API_VALIDATION_MISSING",
|
|
8
|
+
title: "No server-side schema validation library detected in API surface",
|
|
9
|
+
severity: "HIGH",
|
|
10
|
+
requiredActions: [
|
|
11
|
+
"Add mandatory server-side schema validation for all API boundaries (Zod/Valibot/Yup/Joi).",
|
|
12
|
+
"Enforce allowlist validation and strict normalization at boundaries."
|
|
13
|
+
]
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
const csrfHits = await searchRepo({ query: "csrf|xsrf", isRegex: true, maxMatches: 200 });
|
|
17
|
+
if (csrfHits.length === 0) {
|
|
18
|
+
findings.push({
|
|
19
|
+
id: "CSRF_MAY_BE_MISSING",
|
|
20
|
+
title: "CSRF protections not detected",
|
|
21
|
+
severity: "HIGH",
|
|
22
|
+
requiredActions: [
|
|
23
|
+
"Add CSRF protections for all state-changing endpoints.",
|
|
24
|
+
"Use SameSite cookies + CSRF tokens, validate origin/referer for browser contexts."
|
|
25
|
+
]
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const idorCues = await searchRepo({
|
|
29
|
+
query: String.raw `req\.query\.|params\.|userId\s*=`,
|
|
30
|
+
isRegex: true,
|
|
31
|
+
maxMatches: 200
|
|
32
|
+
});
|
|
33
|
+
if (idorCues.length > 0) {
|
|
34
|
+
findings.push({
|
|
35
|
+
id: "IDOR_RISK_REVIEW",
|
|
36
|
+
title: "Possible IDOR risk: parameterized resource access patterns detected",
|
|
37
|
+
severity: "MEDIUM",
|
|
38
|
+
evidence: idorCues.slice(0, 15).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
39
|
+
requiredActions: [
|
|
40
|
+
"Ensure every resource access enforces server-side authz checks (UI checks never count).",
|
|
41
|
+
"Add tests for cross-tenant access attempts."
|
|
42
|
+
]
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return findings;
|
|
46
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fg from "fast-glob";
|
|
2
|
+
import { readFileSafe } from "../../repo/fs.js";
|
|
3
|
+
export async function checkDependencies(_) {
|
|
4
|
+
const findings = [];
|
|
5
|
+
const lockfiles = await fg(["package-lock.json", "pnpm-lock.yaml", "yarn.lock"], { dot: true });
|
|
6
|
+
if (lockfiles.length === 0) {
|
|
7
|
+
findings.push({
|
|
8
|
+
id: "LOCKFILE_MISSING",
|
|
9
|
+
title: "No JS lockfile found",
|
|
10
|
+
severity: "HIGH",
|
|
11
|
+
requiredActions: [
|
|
12
|
+
"Add and commit a lockfile (package-lock.json, pnpm-lock.yaml, or yarn.lock).",
|
|
13
|
+
"Pin versions and enable dependency scanning in CI."
|
|
14
|
+
]
|
|
15
|
+
});
|
|
16
|
+
return findings;
|
|
17
|
+
}
|
|
18
|
+
// Basic check: ensure package.json exists and is valid JSON
|
|
19
|
+
try {
|
|
20
|
+
const pkg = JSON.parse(await readFileSafe("package.json"));
|
|
21
|
+
if (!pkg.dependencies && !pkg.devDependencies) {
|
|
22
|
+
findings.push({
|
|
23
|
+
id: "PACKAGE_JSON_EMPTY",
|
|
24
|
+
title: "package.json has no dependencies/devDependencies",
|
|
25
|
+
severity: "LOW",
|
|
26
|
+
requiredActions: ["Verify this is intentional. If not, add dependencies with pinned ranges."]
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
findings.push({
|
|
32
|
+
id: "PACKAGE_JSON_INVALID",
|
|
33
|
+
title: "package.json is missing or invalid JSON",
|
|
34
|
+
severity: "HIGH",
|
|
35
|
+
requiredActions: ["Fix package.json JSON syntax."]
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return findings;
|
|
39
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { searchRepo } from "../../repo/search.js";
|
|
2
|
+
export async function checkInfra(_) {
|
|
3
|
+
const findings = [];
|
|
4
|
+
const secretManagerRefs = await searchRepo({
|
|
5
|
+
query: "secretmanager|Secret Manager|google_secret_manager",
|
|
6
|
+
isRegex: true,
|
|
7
|
+
maxMatches: 200
|
|
8
|
+
});
|
|
9
|
+
if (secretManagerRefs.length === 0) {
|
|
10
|
+
findings.push({
|
|
11
|
+
id: "SECRET_MANAGER_NOT_DETECTED",
|
|
12
|
+
title: "GCP Secret Manager usage not detected in infra/app config",
|
|
13
|
+
severity: "HIGH",
|
|
14
|
+
requiredActions: [
|
|
15
|
+
"Store secrets only in GCP Secret Manager.",
|
|
16
|
+
"Configure workload identity / service accounts to access secrets, never plaintext env in repo."
|
|
17
|
+
]
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const publicIngress = await searchRepo({
|
|
21
|
+
query: String.raw `0\.0\.0\.0/0|::/0|public\s*=\s*true|allowAll|allUsers`,
|
|
22
|
+
isRegex: true,
|
|
23
|
+
maxMatches: 200
|
|
24
|
+
});
|
|
25
|
+
if (publicIngress.length > 0) {
|
|
26
|
+
findings.push({
|
|
27
|
+
id: "PUBLIC_EXPOSURE_RISK",
|
|
28
|
+
title: "Potential public exposure patterns detected in IaC/config",
|
|
29
|
+
severity: "HIGH",
|
|
30
|
+
evidence: publicIngress.slice(0, 20).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
31
|
+
requiredActions: [
|
|
32
|
+
"Remove or justify public ingress. Enforce Zero Trust. No implicit trust for any request or service call.",
|
|
33
|
+
"Use private services, IAM-based auth, and least-privileged firewall rules."
|
|
34
|
+
]
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return findings;
|
|
38
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fg from "fast-glob";
|
|
2
|
+
import { readFileSafe } from "../../repo/fs.js";
|
|
3
|
+
export async function checkMobileAndroid(_) {
|
|
4
|
+
const findings = [];
|
|
5
|
+
const manifests = await fg(["**/AndroidManifest.xml"], { dot: true, ignore: ["**/node_modules/**"] });
|
|
6
|
+
for (const m of manifests) {
|
|
7
|
+
const xml = await readFileSafe(m).catch(() => "");
|
|
8
|
+
const lower = xml.toLowerCase();
|
|
9
|
+
if (lower.includes('android:debuggable="true"')) {
|
|
10
|
+
findings.push({
|
|
11
|
+
id: "ANDROID_DEBUGGABLE",
|
|
12
|
+
title: "Android app is debuggable in manifest",
|
|
13
|
+
severity: "CRITICAL",
|
|
14
|
+
files: [m],
|
|
15
|
+
requiredActions: [
|
|
16
|
+
"Remove android:debuggable=\"true\" for release builds.",
|
|
17
|
+
"Ensure signing configs and build variants enforce non-debuggable release artifacts."
|
|
18
|
+
]
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
if (lower.includes('android:usescleartexttraffic="true"')) {
|
|
22
|
+
findings.push({
|
|
23
|
+
id: "ANDROID_CLEARTEXT",
|
|
24
|
+
title: "Android cleartext traffic allowed",
|
|
25
|
+
severity: "CRITICAL",
|
|
26
|
+
files: [m],
|
|
27
|
+
requiredActions: [
|
|
28
|
+
"Disable cleartext traffic. Enforce TLS 1.3.",
|
|
29
|
+
"Use Network Security Config with strict domain allowlists if exceptions are required."
|
|
30
|
+
]
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return findings;
|
|
35
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fg from "fast-glob";
|
|
2
|
+
import { readFileSafe } from "../../repo/fs.js";
|
|
3
|
+
export async function checkMobileIos(_) {
|
|
4
|
+
const findings = [];
|
|
5
|
+
const plists = await fg(["**/Info.plist"], { dot: true, ignore: ["**/node_modules/**"] });
|
|
6
|
+
for (const p of plists) {
|
|
7
|
+
const text = await readFileSafe(p).catch(() => "");
|
|
8
|
+
const lower = text.toLowerCase();
|
|
9
|
+
if (lower.includes("nsallowsarbitraryloads") || lower.includes("allowsarbitraryloads")) {
|
|
10
|
+
findings.push({
|
|
11
|
+
id: "IOS_ATS_WEAK",
|
|
12
|
+
title: "iOS ATS appears weakened (NSAllowsArbitraryLoads)",
|
|
13
|
+
severity: "CRITICAL",
|
|
14
|
+
files: [p],
|
|
15
|
+
requiredActions: [
|
|
16
|
+
"Remove NSAllowsArbitraryLoads. Enforce TLS 1.3. Restrict exceptions to specific domains with justification.",
|
|
17
|
+
"Enable certificate pinning for high-risk APIs where appropriate."
|
|
18
|
+
]
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return findings;
|
|
23
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import fg from "fast-glob";
|
|
2
|
+
import picomatch from "picomatch";
|
|
3
|
+
export async function checkRequiredArtifacts(opts) {
|
|
4
|
+
const findings = [];
|
|
5
|
+
for (const req of opts.policy.artifacts_required ?? []) {
|
|
6
|
+
const matchers = req.on_changes.map((pattern) => picomatch(pattern, { dot: true }));
|
|
7
|
+
const touched = opts.changedFiles.some((file) => matchers.some((match) => match(file)));
|
|
8
|
+
if (!touched)
|
|
9
|
+
continue;
|
|
10
|
+
const matches = await fg(req.pattern, { dot: true });
|
|
11
|
+
if (matches.length === 0) {
|
|
12
|
+
findings.push({
|
|
13
|
+
id: "ARTIFACTS_MISSING",
|
|
14
|
+
title: `Missing required artifact(s) for changes affecting: ${req.on_changes.join(", ")}`,
|
|
15
|
+
severity: "HIGH",
|
|
16
|
+
evidence: [`Expected at least one file matching: ${req.pattern}`],
|
|
17
|
+
requiredActions: [
|
|
18
|
+
`Add required artifact(s) matching "${req.pattern}" (e.g., threat model for the changed flow).`,
|
|
19
|
+
`Include STRIDE + OWASP mapping + MITRE mapping + required logging and tests.`
|
|
20
|
+
]
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return findings;
|
|
25
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
export async function checkSecrets(_) {
|
|
3
|
+
// CI will also run gitleaks. This is a fast local heuristic backup.
|
|
4
|
+
const findings = [];
|
|
5
|
+
const patterns = [
|
|
6
|
+
"-----BEGIN PRIVATE KEY-----",
|
|
7
|
+
"AKIA", // AWS
|
|
8
|
+
"AIza", // Google API key prefix
|
|
9
|
+
"xoxb-", // Slack bot token
|
|
10
|
+
"sk-", // common LLM key prefix
|
|
11
|
+
"SECRET_KEY",
|
|
12
|
+
"PRIVATE_KEY"
|
|
13
|
+
];
|
|
14
|
+
const { stdout } = await execa("git", ["grep", "-n", "--untracked", "--no-index", "-I", "-e", patterns.join("|"), "."], {
|
|
15
|
+
reject: false
|
|
16
|
+
});
|
|
17
|
+
if (stdout.trim()) {
|
|
18
|
+
findings.push({
|
|
19
|
+
id: "POSSIBLE_SECRET",
|
|
20
|
+
title: "Potential secret material detected by heuristic scan",
|
|
21
|
+
severity: "CRITICAL",
|
|
22
|
+
evidence: stdout.split("\n").slice(0, 50),
|
|
23
|
+
requiredActions: [
|
|
24
|
+
"Remove secrets from repo immediately.",
|
|
25
|
+
"Rotate any exposed credentials.",
|
|
26
|
+
"Store secrets only in GCP Secret Manager. Do not log secrets."
|
|
27
|
+
]
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return findings;
|
|
31
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { searchRepo } from "../../repo/search.js";
|
|
2
|
+
import fg from "fast-glob";
|
|
3
|
+
import { readFileSafe } from "../../repo/fs.js";
|
|
4
|
+
export async function checkWebNextjs(_) {
|
|
5
|
+
const findings = [];
|
|
6
|
+
// 1) CSP and security headers should exist (Next middleware or edge config)
|
|
7
|
+
const headerFiles = await fg(["middleware.ts", "middleware.tsx", "src/middleware.ts", "next.config.*"], {
|
|
8
|
+
dot: true
|
|
9
|
+
});
|
|
10
|
+
if (headerFiles.length === 0) {
|
|
11
|
+
findings.push({
|
|
12
|
+
id: "WEB_HEADERS_MISSING",
|
|
13
|
+
title: "Security headers not found (CSP/HSTS/etc.)",
|
|
14
|
+
severity: "HIGH",
|
|
15
|
+
requiredActions: [
|
|
16
|
+
"Add strict security headers: CSP (no inline JS), HSTS, X-Frame-Options, Referrer-Policy, Permissions-Policy.",
|
|
17
|
+
"Enforce secure cookies: HttpOnly, Secure, SameSite, short-lived tokens."
|
|
18
|
+
]
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
const combined = (await Promise.all(headerFiles.map((f) => readFileSafe(f).catch(() => "")))).join("\n");
|
|
23
|
+
const mustContain = [
|
|
24
|
+
"content-security-policy",
|
|
25
|
+
"strict-transport-security",
|
|
26
|
+
"referrer-policy",
|
|
27
|
+
"permissions-policy"
|
|
28
|
+
];
|
|
29
|
+
const missing = mustContain.filter((k) => !combined.toLowerCase().includes(k));
|
|
30
|
+
if (missing.length > 0) {
|
|
31
|
+
findings.push({
|
|
32
|
+
id: "WEB_HEADERS_INCOMPLETE",
|
|
33
|
+
title: "Security headers exist but appear incomplete",
|
|
34
|
+
severity: "HIGH",
|
|
35
|
+
evidence: [`Missing: ${missing.join(", ")}`],
|
|
36
|
+
requiredActions: [
|
|
37
|
+
"Add missing headers and ensure CSP forbids inline scripts (no 'unsafe-inline').",
|
|
38
|
+
"Add a CSP nonce strategy if you must load dynamic scripts."
|
|
39
|
+
]
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// 2) Flag dangerous React usage
|
|
44
|
+
const dsi = await searchRepo({ query: "dangerouslySetInnerHTML", isRegex: false, maxMatches: 200 });
|
|
45
|
+
if (dsi.length > 0) {
|
|
46
|
+
findings.push({
|
|
47
|
+
id: "DANGEROUSLY_SET_INNER_HTML",
|
|
48
|
+
title: "dangerouslySetInnerHTML usage detected",
|
|
49
|
+
severity: "HIGH",
|
|
50
|
+
evidence: dsi.slice(0, 20).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
51
|
+
requiredActions: [
|
|
52
|
+
"Remove dangerouslySetInnerHTML where possible.",
|
|
53
|
+
"If unavoidable: sanitize with a proven HTML sanitizer and add unit tests with XSS payloads."
|
|
54
|
+
]
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// 3) Basic SSRF risk pattern scan (server-side fetch)
|
|
58
|
+
const fetchHits = await searchRepo({
|
|
59
|
+
query: String.raw `\bfetch\(|axios\(|got\(|undici\b`,
|
|
60
|
+
isRegex: true,
|
|
61
|
+
maxMatches: 200
|
|
62
|
+
});
|
|
63
|
+
if (fetchHits.length > 0) {
|
|
64
|
+
findings.push({
|
|
65
|
+
id: "SSRF_GUARD_REQUIRED",
|
|
66
|
+
title: "Server-side fetch patterns detected. SSRF protections must be enforced.",
|
|
67
|
+
severity: "HIGH",
|
|
68
|
+
evidence: fetchHits.slice(0, 15).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
69
|
+
requiredActions: [
|
|
70
|
+
"Implement SSRF guard for any server-side HTTP client: block localhost, private IP ranges, and cloud metadata endpoints.",
|
|
71
|
+
"Require URL allowlists for outbound calls. Add tests for 127.0.0.1, 10/8, 172.16/12, 192.168/16, 169.254.169.254, metadata.google.internal."
|
|
72
|
+
]
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return findings;
|
|
76
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
export async function getChangedFiles(opts) {
|
|
3
|
+
// Uses git diff in CI. Assumes checkout has full history for baseRef.
|
|
4
|
+
const { stdout } = await execa("git", ["diff", "--name-only", `${opts.baseRef}...${opts.headRef}`], {
|
|
5
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
6
|
+
});
|
|
7
|
+
return stdout
|
|
8
|
+
.split("\n")
|
|
9
|
+
.map((s) => s.trim())
|
|
10
|
+
.filter(Boolean);
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function detectSurfaces(changedFiles) {
|
|
2
|
+
const has = (re) => changedFiles.some((f) => re.test(f));
|
|
3
|
+
return {
|
|
4
|
+
web: has(/^(app|pages|components|src)\/.*\.(ts|tsx|js|jsx)$/) || has(/^next\.config\./),
|
|
5
|
+
api: has(/^(app\/api|src\/api|api|server)\//),
|
|
6
|
+
infra: has(/^(infra|terraform|iac|k8s|helm|cloudbuild|\.github\/workflows)\//),
|
|
7
|
+
mobileIos: has(/^(ios|.*\.xcodeproj|.*\.xcworkspace|.*Info\.plist|Podfile)/),
|
|
8
|
+
mobileAndroid: has(/^(android|.*\/AndroidManifest\.xml|.*\/build\.gradle(\.kts)?|gradle\.properties)/),
|
|
9
|
+
ai: has(/^(ai|llm|prompt|rag|agents)\//) || has(/(openai|anthropic|vertexai|langchain|llamaindex)/)
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getChangedFiles } from "./diff.js";
|
|
3
|
+
import { detectSurfaces } from "./findings.js";
|
|
4
|
+
import { checkRequiredArtifacts } from "./checks/required-artifacts.js";
|
|
5
|
+
import { checkSecrets } from "./checks/secrets.js";
|
|
6
|
+
import { checkDependencies } from "./checks/dependencies.js";
|
|
7
|
+
import { checkWebNextjs } from "./checks/web-nextjs.js";
|
|
8
|
+
import { checkApi } from "./checks/api.js";
|
|
9
|
+
import { checkInfra } from "./checks/infra.js";
|
|
10
|
+
import { checkMobileIos } from "./checks/mobile-ios.js";
|
|
11
|
+
import { checkMobileAndroid } from "./checks/mobile-android.js";
|
|
12
|
+
import { checkAi } from "./checks/ai.js";
|
|
13
|
+
import { readFileSafe } from "../repo/fs.js";
|
|
14
|
+
const PolicySchema = z.object({
|
|
15
|
+
name: z.string(),
|
|
16
|
+
version: z.string(),
|
|
17
|
+
artifacts_required: z
|
|
18
|
+
.array(z.object({
|
|
19
|
+
pattern: z.string(),
|
|
20
|
+
on_changes: z.array(z.string())
|
|
21
|
+
}))
|
|
22
|
+
.default([]),
|
|
23
|
+
required_checks: z.record(z.any()).default({}),
|
|
24
|
+
requirements: z
|
|
25
|
+
.array(z.object({
|
|
26
|
+
id: z.string(),
|
|
27
|
+
type: z.enum(["gate", "control"]).default("gate"),
|
|
28
|
+
evidence: z.array(z.string()).default([])
|
|
29
|
+
}))
|
|
30
|
+
.default([])
|
|
31
|
+
});
|
|
32
|
+
export async function loadPolicy(policyPath) {
|
|
33
|
+
const raw = await readFileSafe(policyPath);
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
return PolicySchema.parse(parsed);
|
|
36
|
+
}
|
|
37
|
+
export async function runPrGate(opts) {
|
|
38
|
+
const policy = await loadPolicy(opts.policyPath);
|
|
39
|
+
const changedFiles = await getChangedFiles({
|
|
40
|
+
baseRef: opts.baseRef ?? "origin/main",
|
|
41
|
+
headRef: opts.headRef ?? "HEAD"
|
|
42
|
+
});
|
|
43
|
+
const surfaces = detectSurfaces(changedFiles);
|
|
44
|
+
const findings = [
|
|
45
|
+
// Required artifacts first: threat models/checklists.
|
|
46
|
+
...(await checkRequiredArtifacts({ policy, changedFiles })),
|
|
47
|
+
// Baseline scans / checks
|
|
48
|
+
...(await checkSecrets({ changedFiles })),
|
|
49
|
+
...(await checkDependencies({ changedFiles })),
|
|
50
|
+
// Surface-specific checks (only run if that surface is impacted or exists)
|
|
51
|
+
...(surfaces.web ? await checkWebNextjs({ changedFiles }) : []),
|
|
52
|
+
...(surfaces.api ? await checkApi({ changedFiles }) : []),
|
|
53
|
+
...(surfaces.infra ? await checkInfra({ changedFiles }) : []),
|
|
54
|
+
...(surfaces.mobileIos ? await checkMobileIos({ changedFiles }) : []),
|
|
55
|
+
...(surfaces.mobileAndroid ? await checkMobileAndroid({ changedFiles }) : []),
|
|
56
|
+
...(surfaces.ai ? await checkAi({ changedFiles }) : [])
|
|
57
|
+
];
|
|
58
|
+
const status = findings.some((f) => f.severity === "HIGH" || f.severity === "CRITICAL")
|
|
59
|
+
? "FAIL"
|
|
60
|
+
: "PASS";
|
|
61
|
+
return {
|
|
62
|
+
status,
|
|
63
|
+
policyVersion: policy.version,
|
|
64
|
+
evaluatedAt: new Date().toISOString(),
|
|
65
|
+
scope: { changedFiles, surfaces },
|
|
66
|
+
findings
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|