security-mcp 1.0.5 → 1.1.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/defaults/checklists/ai.json +25 -0
- package/defaults/checklists/api.json +27 -0
- package/defaults/checklists/infra.json +27 -0
- package/defaults/checklists/mobile.json +25 -0
- package/defaults/checklists/payments.json +25 -0
- package/defaults/checklists/web.json +30 -0
- package/defaults/control-catalog.json +392 -0
- package/defaults/evidence-map.json +194 -0
- package/defaults/security-policy.json +41 -2
- package/dist/cli/index.js +13 -8
- package/dist/cli/install.js +11 -0
- package/dist/cli/onboarding.js +590 -0
- package/dist/gate/baseline.js +115 -0
- package/dist/gate/checks/ai-redteam.js +374 -0
- package/dist/gate/checks/api.js +93 -0
- package/dist/gate/checks/crypto.js +153 -0
- package/dist/gate/checks/database.js +144 -0
- package/dist/gate/checks/dependencies.js +126 -0
- package/dist/gate/checks/dlp.js +153 -0
- package/dist/gate/checks/graphql.js +122 -0
- package/dist/gate/checks/infra.js +126 -12
- package/dist/gate/checks/k8s.js +190 -0
- package/dist/gate/checks/playbook.js +160 -0
- package/dist/gate/checks/runtime.js +263 -0
- package/dist/gate/checks/sbom.js +199 -0
- package/dist/gate/checks/scanners.js +373 -7
- package/dist/gate/checks/secrets.js +85 -20
- package/dist/gate/policy.js +85 -19
- package/dist/gate/threat-intel.js +157 -0
- package/dist/mcp/server.js +500 -5
- package/dist/repo/search.js +13 -1
- package/dist/review/store.js +128 -0
- package/package.json +1 -1
- package/prompts/SECURITY_PROMPT.md +415 -1
- package/skills/senior-security-engineer/SKILL.md +35 -3
package/dist/mcp/server.js
CHANGED
|
@@ -62,7 +62,7 @@ const StartReviewParams = {
|
|
|
62
62
|
headRef: z.string().optional().describe("Only for recent_changes mode. Head git ref, default HEAD.")
|
|
63
63
|
};
|
|
64
64
|
const StartReviewSchema = z.object(StartReviewParams);
|
|
65
|
-
tool("security.start_review", "Start a stateful security review run, lock the scan mode, and return a run ID for ordered execution and attestation.", StartReviewParams, safeTool(async (args, _extra) => {
|
|
65
|
+
tool("security.start_review", "Start a stateful security review run, lock the scan mode, and return a run ID for ordered execution and attestation. OPERATING MANDATE: 90% fixing, 10% advisory. You do not list vulnerabilities and walk away — you write the fix, implement the control, and enforce the policy.", StartReviewParams, safeTool(async (args, _extra) => {
|
|
66
66
|
const { mode, targets, baseRef, headRef } = StartReviewSchema.parse(args);
|
|
67
67
|
const cleanTargets = (targets ?? []).map((target) => target.trim()).filter(Boolean);
|
|
68
68
|
if ((mode === "folder_by_folder" || mode === "file_by_file") && cleanTargets.length === 0) {
|
|
@@ -82,6 +82,7 @@ tool("security.start_review", "Start a stateful security review run, lock the sc
|
|
|
82
82
|
baseRef: baseRef ?? "origin/main",
|
|
83
83
|
headRef: headRef ?? "HEAD",
|
|
84
84
|
requiredSteps: run.requiredSteps,
|
|
85
|
+
operatingMandate: "90% fixing, 10% advisory. Write the fix. Implement the control. Enforce the policy. Do not list vulnerabilities and walk away.",
|
|
85
86
|
nextSteps: [
|
|
86
87
|
"Run security.threat_model with this runId.",
|
|
87
88
|
"Run security.checklist with this runId.",
|
|
@@ -199,9 +200,20 @@ const GetSystemPromptParams = {
|
|
|
199
200
|
payment_processor: z.string().optional().describe("Payment processor in use, e.g. 'Stripe', 'Braintree', 'Adyen', or 'none'.")
|
|
200
201
|
};
|
|
201
202
|
const GetSystemPromptSchema = z.object(GetSystemPromptParams);
|
|
202
|
-
tool("security.get_system_prompt", "Return the full security engineering system prompt. Optionally customized with your stack, cloud provider, and payment processor. Use this as the system prompt to configure Claude as an elite security engineer for your project.", GetSystemPromptParams, safeTool(async (args, _extra) => {
|
|
203
|
+
tool("security.get_system_prompt", "Return the full security engineering system prompt. Optionally customized with your stack, cloud provider, and payment processor. Use this as the system prompt to configure Claude as an elite security engineer for your project. Core operating ratio: 90% fixing, 10% advisory — write the fix, implement the control, enforce the policy.", GetSystemPromptParams, safeTool(async (args, _extra) => {
|
|
203
204
|
const { stack, cloud, payment_processor } = GetSystemPromptSchema.parse(args);
|
|
204
|
-
|
|
205
|
+
// Prepend the operating mandate so it is the first instruction the model reads,
|
|
206
|
+
// regardless of which part of the prompt file is loaded or truncated.
|
|
207
|
+
const OPERATING_MANDATE = "# CORE OPERATING MANDATE — READ FIRST\n\n" +
|
|
208
|
+
"Your operating ratio is **90% fixing, 10% advisory**.\n" +
|
|
209
|
+
"You do NOT list vulnerabilities and walk away.\n" +
|
|
210
|
+
"You write the fix. You implement the control. You enforce the policy.\n\n" +
|
|
211
|
+
"**90% action:** Write the secure code directly. Implement validation, middleware, " +
|
|
212
|
+
"access controls, and secret management. Produce production-ready fixes every time.\n\n" +
|
|
213
|
+
"**10% explanation:** One line — what was wrong, what attack it prevents, which framework " +
|
|
214
|
+
"control applies (OWASP, ATT&CK, NIST). Then move on.\n\n" +
|
|
215
|
+
"---\n\n";
|
|
216
|
+
let prompt = OPERATING_MANDATE + SECURITY_PROMPT;
|
|
205
217
|
// Append a project-specific scope section if any context was provided
|
|
206
218
|
if (stack ?? cloud ?? payment_processor) {
|
|
207
219
|
const scopeLines = [
|
|
@@ -808,6 +820,85 @@ deny contains msg if {
|
|
|
808
820
|
}
|
|
809
821
|
};
|
|
810
822
|
const selected = policyByPack[selectedPack];
|
|
823
|
+
// Generate test file for the selected policy pack
|
|
824
|
+
const testPackageName = `security.${selectedPack.replace(/_/g, "")}_test`;
|
|
825
|
+
const testPolicy = `package ${testPackageName}
|
|
826
|
+
|
|
827
|
+
import rego.v1
|
|
828
|
+
|
|
829
|
+
# --- Allow cases (should NOT produce deny) ---
|
|
830
|
+
|
|
831
|
+
test_allow_valid_resource if {
|
|
832
|
+
count(deny) == 0 with input as {
|
|
833
|
+
"resource_changes": []
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
test_allow_encrypted_storage if {
|
|
838
|
+
count(deny) == 0 with input as {
|
|
839
|
+
"resource_changes": [{
|
|
840
|
+
"type": "aws_s3_bucket",
|
|
841
|
+
"address": "aws_s3_bucket.secure",
|
|
842
|
+
"change": { "after": { "public": false, "encryption": true } }
|
|
843
|
+
}]
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
test_allow_private_ingress if {
|
|
848
|
+
count(deny) == 0 with input as {
|
|
849
|
+
"resource_changes": [{
|
|
850
|
+
"type": "aws_security_group_rule",
|
|
851
|
+
"change": { "after": { "type": "ingress", "cidr_blocks": ["10.0.0.0/8"] } }
|
|
852
|
+
}]
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
# --- Deny cases (should produce deny) ---
|
|
857
|
+
|
|
858
|
+
test_deny_public_ingress if {
|
|
859
|
+
count(deny) > 0 with input as {
|
|
860
|
+
"resource_changes": [{
|
|
861
|
+
"type": "aws_security_group_rule",
|
|
862
|
+
"change": { "after": { "type": "ingress", "cidr_blocks": ["0.0.0.0/0"] } }
|
|
863
|
+
}]
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
test_deny_public_storage if {
|
|
868
|
+
count(deny) > 0 with input as {
|
|
869
|
+
"resource_changes": [{
|
|
870
|
+
"type": "aws_s3_bucket",
|
|
871
|
+
"address": "aws_s3_bucket.bad",
|
|
872
|
+
"change": { "after": { "public": true, "encryption": false } }
|
|
873
|
+
}]
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
test_deny_unencrypted_database if {
|
|
878
|
+
count(deny) > 0 with input as {
|
|
879
|
+
"resource_changes": [{
|
|
880
|
+
"type": "aws_db_instance",
|
|
881
|
+
"address": "aws_db_instance.bad",
|
|
882
|
+
"change": { "after": { "encryption": false } }
|
|
883
|
+
}]
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
# --- Edge cases ---
|
|
888
|
+
|
|
889
|
+
test_empty_input if {
|
|
890
|
+
count(deny) == 0 with input as {}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
test_null_resource_changes if {
|
|
894
|
+
count(deny) == 0 with input as { "resource_changes": [] }
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
test_missing_required_fields if {
|
|
898
|
+
count(deny) == 0 with input as { "resource_changes": [{ "type": "unknown_type", "change": {} }] }
|
|
899
|
+
}
|
|
900
|
+
`;
|
|
901
|
+
const testFilePath = selected.path.replace(".rego", "_test.rego");
|
|
811
902
|
if (runId) {
|
|
812
903
|
await updateReviewStep(runId, "generate_opa_rego", "approved", {
|
|
813
904
|
policyPack: selectedPack,
|
|
@@ -816,10 +907,14 @@ deny contains msg if {
|
|
|
816
907
|
}
|
|
817
908
|
return asTextResponse({
|
|
818
909
|
generated_for: { policyPack: selectedPack, cloud: cloud ?? "multi" },
|
|
819
|
-
files: [
|
|
910
|
+
files: [
|
|
911
|
+
selected,
|
|
912
|
+
{ path: testFilePath, policy: testPolicy, description: "OPA test file — run with: opa test policy/ -v" }
|
|
913
|
+
],
|
|
820
914
|
install_notes: [
|
|
821
915
|
"Run this in CI before deployment apply/admission.",
|
|
822
916
|
"Fail the pipeline when any deny rules are returned.",
|
|
917
|
+
"Run tests with: opa test policy/ -v",
|
|
823
918
|
"Version-control the policy and require security-owner approval for policy exceptions."
|
|
824
919
|
]
|
|
825
920
|
});
|
|
@@ -868,9 +963,409 @@ tool("security.self_heal_loop", "Propose a human-approved self-healing improveme
|
|
|
868
963
|
});
|
|
869
964
|
}));
|
|
870
965
|
// ---------------------------------------------------------------------------
|
|
966
|
+
// New tool: security.generate_compliance_report
|
|
967
|
+
// ---------------------------------------------------------------------------
|
|
968
|
+
const GenerateComplianceReportParams = {
|
|
969
|
+
...ReviewRunIdParam,
|
|
970
|
+
framework: z.enum(["SOC2", "PCI-DSS", "ISO27001", "NIST-800-53", "HIPAA", "GDPR"]).describe("Compliance framework to evaluate against."),
|
|
971
|
+
outputFormat: z.enum(["json", "markdown"]).default("markdown").describe("Output format.")
|
|
972
|
+
};
|
|
973
|
+
const GenerateComplianceReportSchema = z.object(GenerateComplianceReportParams);
|
|
974
|
+
tool("security.generate_compliance_report", "Generate a compliance gap analysis report mapping gate results to a specific framework's controls. Identifies satisfied, missing, and partially-satisfied controls with evidence artifacts.", GenerateComplianceReportParams, safeTool(async (args, _extra) => {
|
|
975
|
+
const { runId, framework, outputFormat } = GenerateComplianceReportSchema.parse(args);
|
|
976
|
+
// Framework → control prefix/tag mapping
|
|
977
|
+
const frameworkFilters = {
|
|
978
|
+
"SOC2": ["SOC2_", "SOC 2"],
|
|
979
|
+
"PCI-DSS": ["PCI_", "PCI DSS"],
|
|
980
|
+
"ISO27001": ["ISO_", "ISO 27001"],
|
|
981
|
+
"NIST-800-53": ["NIST_", "NIST 800-53"],
|
|
982
|
+
"HIPAA": ["HIPAA"],
|
|
983
|
+
"GDPR": ["GDPR"]
|
|
984
|
+
};
|
|
985
|
+
const filters = frameworkFilters[framework] ?? [];
|
|
986
|
+
// Load gate result from run if provided
|
|
987
|
+
let gateFindings = [];
|
|
988
|
+
let gateStatus = "UNKNOWN";
|
|
989
|
+
if (runId) {
|
|
990
|
+
try {
|
|
991
|
+
const { readReviewRun } = await import("../review/store.js");
|
|
992
|
+
const run = await readReviewRun(runId);
|
|
993
|
+
const gateStep = run.steps["run_pr_gate"];
|
|
994
|
+
if (gateStep?.details) {
|
|
995
|
+
const details = gateStep.details;
|
|
996
|
+
gateStatus = String(details["status"] ?? "UNKNOWN");
|
|
997
|
+
gateFindings = details["findings"] ?? [];
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
catch {
|
|
1001
|
+
// run not found — proceed without gate data
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
// Load control catalog
|
|
1005
|
+
const { loadControlCatalog } = await import("../gate/catalog.js");
|
|
1006
|
+
const catalog = await loadControlCatalog();
|
|
1007
|
+
// Filter controls by framework
|
|
1008
|
+
const frameworkControls = catalog.controls.filter((c) => filters.some((f) => c.id.startsWith(f) || c.frameworks.some((fw) => fw.includes(f.trim()))));
|
|
1009
|
+
const controlStatuses = frameworkControls.map((c) => {
|
|
1010
|
+
const matchingFinding = gateFindings.find((f) => f.id.startsWith(c.id) || c.id.includes(f.id));
|
|
1011
|
+
if (matchingFinding) {
|
|
1012
|
+
return { id: c.id, description: c.description, status: "missing", evidence: [`Finding: ${matchingFinding.id} (${matchingFinding.severity})`] };
|
|
1013
|
+
}
|
|
1014
|
+
// If no adverse finding, consider it tentatively satisfied
|
|
1015
|
+
return { id: c.id, description: c.description, status: "satisfied", evidence: c.evidence ?? [] };
|
|
1016
|
+
});
|
|
1017
|
+
const total = controlStatuses.length;
|
|
1018
|
+
const satisfied = controlStatuses.filter((c) => c.status === "satisfied").length;
|
|
1019
|
+
const missing = controlStatuses.filter((c) => c.status === "missing").length;
|
|
1020
|
+
const partial = controlStatuses.filter((c) => c.status === "partial").length;
|
|
1021
|
+
if (outputFormat === "json") {
|
|
1022
|
+
return asTextResponse({
|
|
1023
|
+
framework,
|
|
1024
|
+
runId: runId ?? null,
|
|
1025
|
+
gateStatus,
|
|
1026
|
+
summary: { total, satisfied, missing, partial },
|
|
1027
|
+
controls: controlStatuses
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
// Markdown output
|
|
1031
|
+
const rows = controlStatuses.map((c) => {
|
|
1032
|
+
const icon = c.status === "satisfied" ? "✓" : c.status === "missing" ? "✗" : "~";
|
|
1033
|
+
const evidence = c.evidence.slice(0, 2).join("; ") || "-";
|
|
1034
|
+
return `| ${c.id} | ${c.description.slice(0, 60)} | ${icon} ${c.status} | ${evidence} |`;
|
|
1035
|
+
}).join("\n");
|
|
1036
|
+
const report = `# Compliance Gap Analysis: ${framework}
|
|
1037
|
+
|
|
1038
|
+
**Run ID**: ${runId ?? "not provided"}
|
|
1039
|
+
**Gate Status**: ${gateStatus}
|
|
1040
|
+
**Generated**: ${new Date().toISOString()}
|
|
1041
|
+
|
|
1042
|
+
## Summary
|
|
1043
|
+
|
|
1044
|
+
| Metric | Count |
|
|
1045
|
+
|---|---|
|
|
1046
|
+
| Total Controls | ${total} |
|
|
1047
|
+
| Satisfied | ${satisfied} |
|
|
1048
|
+
| Missing | ${missing} |
|
|
1049
|
+
| Partial | ${partial} |
|
|
1050
|
+
| Coverage | ${total > 0 ? Math.round((satisfied / total) * 100) : 0}% |
|
|
1051
|
+
|
|
1052
|
+
## Control Details
|
|
1053
|
+
|
|
1054
|
+
| Control ID | Description | Status | Evidence |
|
|
1055
|
+
|---|---|---|---|
|
|
1056
|
+
${rows}
|
|
1057
|
+
`;
|
|
1058
|
+
return asTextResponse(report);
|
|
1059
|
+
}));
|
|
1060
|
+
// ---------------------------------------------------------------------------
|
|
1061
|
+
// New tool: security.notify_webhooks
|
|
1062
|
+
// ---------------------------------------------------------------------------
|
|
1063
|
+
const NotifyWebhooksParams = {
|
|
1064
|
+
runId: z.string().uuid().describe("Security review run ID whose findings to send."),
|
|
1065
|
+
gateFailed: z.boolean().describe("Whether the gate failed (determines alert severity)."),
|
|
1066
|
+
findingCount: z.number().int().describe("Total number of findings."),
|
|
1067
|
+
criticalCount: z.number().int().describe("Number of CRITICAL findings.")
|
|
1068
|
+
};
|
|
1069
|
+
const NotifyWebhooksSchema = z.object(NotifyWebhooksParams);
|
|
1070
|
+
tool("security.notify_webhooks", "Send security gate findings to configured external systems (Slack, Jira, PagerDuty, generic webhook). Configure endpoints via environment variables: SECURITY_SLACK_WEBHOOK, SECURITY_JIRA_URL+SECURITY_JIRA_TOKEN, SECURITY_PAGERDUTY_KEY, SECURITY_WEBHOOK_URL.", NotifyWebhooksParams, safeTool(async (args, _extra) => {
|
|
1071
|
+
const { runId, gateFailed, findingCount, criticalCount } = NotifyWebhooksSchema.parse(args);
|
|
1072
|
+
const notified = [];
|
|
1073
|
+
const errors = [];
|
|
1074
|
+
// Slack
|
|
1075
|
+
const slackWebhook = process.env["SECURITY_SLACK_WEBHOOK"];
|
|
1076
|
+
if (slackWebhook) {
|
|
1077
|
+
try {
|
|
1078
|
+
const color = gateFailed ? "#d32f2f" : "#388e3c";
|
|
1079
|
+
const statusEmoji = gateFailed ? ":red_circle:" : ":large_green_circle:";
|
|
1080
|
+
const body = {
|
|
1081
|
+
blocks: [
|
|
1082
|
+
{
|
|
1083
|
+
type: "header",
|
|
1084
|
+
text: { type: "plain_text", text: `${statusEmoji} Security Gate ${gateFailed ? "FAILED" : "PASSED"}` }
|
|
1085
|
+
},
|
|
1086
|
+
{
|
|
1087
|
+
type: "section",
|
|
1088
|
+
fields: [
|
|
1089
|
+
{ type: "mrkdwn", text: `*Run ID*: ${runId}` },
|
|
1090
|
+
{ type: "mrkdwn", text: `*Total Findings*: ${findingCount}` },
|
|
1091
|
+
{ type: "mrkdwn", text: `*Critical Findings*: ${criticalCount}` }
|
|
1092
|
+
]
|
|
1093
|
+
}
|
|
1094
|
+
],
|
|
1095
|
+
attachments: [{ color }]
|
|
1096
|
+
};
|
|
1097
|
+
const controller = new AbortController();
|
|
1098
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
1099
|
+
try {
|
|
1100
|
+
const resp = await fetch(slackWebhook, {
|
|
1101
|
+
method: "POST",
|
|
1102
|
+
headers: { "Content-Type": "application/json" },
|
|
1103
|
+
body: JSON.stringify(body),
|
|
1104
|
+
signal: controller.signal
|
|
1105
|
+
});
|
|
1106
|
+
if (resp.ok)
|
|
1107
|
+
notified.push("slack");
|
|
1108
|
+
else
|
|
1109
|
+
errors.push(`slack: HTTP ${resp.status}`);
|
|
1110
|
+
}
|
|
1111
|
+
finally {
|
|
1112
|
+
clearTimeout(timeout);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
catch (e) {
|
|
1116
|
+
errors.push(`slack: ${e instanceof Error ? e.message : "unknown error"}`);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
// PagerDuty
|
|
1120
|
+
const pdKey = process.env["SECURITY_PAGERDUTY_KEY"];
|
|
1121
|
+
if (pdKey && gateFailed && criticalCount > 0) {
|
|
1122
|
+
try {
|
|
1123
|
+
const body = {
|
|
1124
|
+
routing_key: pdKey,
|
|
1125
|
+
event_action: "trigger",
|
|
1126
|
+
payload: {
|
|
1127
|
+
summary: `Security Gate FAILED — ${criticalCount} critical findings (run: ${runId})`,
|
|
1128
|
+
severity: "critical",
|
|
1129
|
+
source: "security-mcp",
|
|
1130
|
+
custom_details: { runId, findingCount, criticalCount }
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
const controller = new AbortController();
|
|
1134
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
1135
|
+
try {
|
|
1136
|
+
const resp = await fetch("https://events.pagerduty.com/v2/enqueue", {
|
|
1137
|
+
method: "POST",
|
|
1138
|
+
headers: { "Content-Type": "application/json" },
|
|
1139
|
+
body: JSON.stringify(body),
|
|
1140
|
+
signal: controller.signal
|
|
1141
|
+
});
|
|
1142
|
+
if (resp.ok)
|
|
1143
|
+
notified.push("pagerduty");
|
|
1144
|
+
else
|
|
1145
|
+
errors.push(`pagerduty: HTTP ${resp.status}`);
|
|
1146
|
+
}
|
|
1147
|
+
finally {
|
|
1148
|
+
clearTimeout(timeout);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
catch (e) {
|
|
1152
|
+
errors.push(`pagerduty: ${e instanceof Error ? e.message : "unknown error"}`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
// Generic webhook
|
|
1156
|
+
const genericWebhook = process.env["SECURITY_WEBHOOK_URL"];
|
|
1157
|
+
if (genericWebhook) {
|
|
1158
|
+
try {
|
|
1159
|
+
const body = { runId, gateFailed, findingCount, criticalCount, timestamp: new Date().toISOString() };
|
|
1160
|
+
const controller = new AbortController();
|
|
1161
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
1162
|
+
try {
|
|
1163
|
+
const resp = await fetch(genericWebhook, {
|
|
1164
|
+
method: "POST",
|
|
1165
|
+
headers: { "Content-Type": "application/json" },
|
|
1166
|
+
body: JSON.stringify(body),
|
|
1167
|
+
signal: controller.signal
|
|
1168
|
+
});
|
|
1169
|
+
if (resp.ok)
|
|
1170
|
+
notified.push("webhook");
|
|
1171
|
+
else
|
|
1172
|
+
errors.push(`webhook: HTTP ${resp.status}`);
|
|
1173
|
+
}
|
|
1174
|
+
finally {
|
|
1175
|
+
clearTimeout(timeout);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
catch (e) {
|
|
1179
|
+
errors.push(`webhook: ${e instanceof Error ? e.message : "unknown error"}`);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
// Jira
|
|
1183
|
+
const jiraUrl = process.env["SECURITY_JIRA_URL"];
|
|
1184
|
+
const jiraToken = process.env["SECURITY_JIRA_TOKEN"];
|
|
1185
|
+
const jiraProject = process.env["SECURITY_JIRA_PROJECT"] ?? "SECURITY";
|
|
1186
|
+
if (jiraUrl && jiraToken && gateFailed) {
|
|
1187
|
+
try {
|
|
1188
|
+
const body = {
|
|
1189
|
+
fields: {
|
|
1190
|
+
project: { key: jiraProject },
|
|
1191
|
+
summary: `Security Gate FAILED - ${criticalCount} critical findings`,
|
|
1192
|
+
description: {
|
|
1193
|
+
type: "doc",
|
|
1194
|
+
version: 1,
|
|
1195
|
+
content: [{
|
|
1196
|
+
type: "paragraph",
|
|
1197
|
+
content: [{ type: "text", text: `Run ID: ${runId}. Total findings: ${findingCount}. Critical: ${criticalCount}.` }]
|
|
1198
|
+
}]
|
|
1199
|
+
},
|
|
1200
|
+
issuetype: { name: "Bug" },
|
|
1201
|
+
priority: { name: criticalCount > 0 ? "Critical" : "High" }
|
|
1202
|
+
}
|
|
1203
|
+
};
|
|
1204
|
+
const controller = new AbortController();
|
|
1205
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
1206
|
+
try {
|
|
1207
|
+
const resp = await fetch(`${jiraUrl}/rest/api/3/issue`, {
|
|
1208
|
+
method: "POST",
|
|
1209
|
+
headers: {
|
|
1210
|
+
"Content-Type": "application/json",
|
|
1211
|
+
// Never log the token — pass it only in the header
|
|
1212
|
+
"Authorization": `Bearer ${jiraToken}`
|
|
1213
|
+
},
|
|
1214
|
+
body: JSON.stringify(body),
|
|
1215
|
+
signal: controller.signal
|
|
1216
|
+
});
|
|
1217
|
+
if (resp.ok)
|
|
1218
|
+
notified.push("jira");
|
|
1219
|
+
else
|
|
1220
|
+
errors.push(`jira: HTTP ${resp.status}`);
|
|
1221
|
+
}
|
|
1222
|
+
finally {
|
|
1223
|
+
clearTimeout(timeout);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
catch (e) {
|
|
1227
|
+
errors.push(`jira: ${e instanceof Error ? e.message : "unknown error"}`);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return asTextResponse({
|
|
1231
|
+
notified,
|
|
1232
|
+
errors,
|
|
1233
|
+
summary: notified.length > 0
|
|
1234
|
+
? `Notified: ${notified.join(", ")}`
|
|
1235
|
+
: "No webhook integrations configured. Set SECURITY_SLACK_WEBHOOK, SECURITY_PAGERDUTY_KEY, SECURITY_WEBHOOK_URL, or SECURITY_JIRA_URL+SECURITY_JIRA_TOKEN."
|
|
1236
|
+
});
|
|
1237
|
+
}));
|
|
1238
|
+
const REMEDIATION_MAP = {
|
|
1239
|
+
"POSSIBLE_SECRET": {
|
|
1240
|
+
pattern: "const API_KEY = 'sk-...' // hardcoded secret",
|
|
1241
|
+
fix: "const API_KEY = process.env['API_KEY']; // loaded from secret manager",
|
|
1242
|
+
explanation: "Hardcoded secrets are exposed in source control and logs. Load secrets from environment variables backed by a secret manager (AWS Secrets Manager, HashiCorp Vault, etc.).",
|
|
1243
|
+
references: ["CWE-798", "OWASP Top 10 A07:2021", "NIST 800-53 IA-5"]
|
|
1244
|
+
},
|
|
1245
|
+
"CRYPTO_WEAK_HASH": {
|
|
1246
|
+
pattern: "crypto.createHash('md5').update(data).digest('hex')",
|
|
1247
|
+
fix: "crypto.createHash('sha256').update(data).digest('hex')",
|
|
1248
|
+
explanation: "MD5 and SHA-1 are cryptographically broken. Use SHA-256 or higher.",
|
|
1249
|
+
references: ["NIST SP 800-131A Rev 2", "CWE-327"]
|
|
1250
|
+
},
|
|
1251
|
+
"CRYPTO_WEAK_CIPHER": {
|
|
1252
|
+
pattern: "crypto.createCipheriv('des', key, iv)",
|
|
1253
|
+
fix: "crypto.createCipheriv('aes-256-gcm', key, nonce)",
|
|
1254
|
+
explanation: "DES/RC4/3DES are prohibited by NIST. Use AES-256-GCM for authenticated encryption.",
|
|
1255
|
+
references: ["NIST SP 800-131A Rev 2", "CWE-327", "FIPS 140-3"]
|
|
1256
|
+
},
|
|
1257
|
+
"CRYPTO_INSECURE_RANDOM": {
|
|
1258
|
+
pattern: "const token = Math.random().toString(36).slice(2)",
|
|
1259
|
+
fix: "const token = crypto.randomBytes(32).toString('hex')",
|
|
1260
|
+
explanation: "Math.random() is not cryptographically secure. Use crypto.randomBytes() for tokens, keys, and nonces.",
|
|
1261
|
+
references: ["CWE-338", "OWASP ASVS 2.3.1"]
|
|
1262
|
+
},
|
|
1263
|
+
"CRYPTO_WEAK_JWT_ALGO": {
|
|
1264
|
+
pattern: "jwt.sign(payload, secret, { algorithm: 'HS256' })",
|
|
1265
|
+
fix: "jwt.sign(payload, privateKey, { algorithm: 'RS256' })",
|
|
1266
|
+
explanation: "HS256 requires sharing the signing secret with every verifier. RS256/ES256 use asymmetric keys so verifiers only need the public key.",
|
|
1267
|
+
references: ["RFC 7518", "OWASP JWT Security Cheat Sheet"]
|
|
1268
|
+
},
|
|
1269
|
+
"DB_TLS_DISABLED": {
|
|
1270
|
+
pattern: "postgresql://user:pass@host/db?sslmode=disable",
|
|
1271
|
+
fix: "postgresql://user:pass@host/db?sslmode=verify-full",
|
|
1272
|
+
explanation: "Disabling TLS exposes credentials and data in transit. Always require and verify TLS.",
|
|
1273
|
+
references: ["PCI DSS 4.0 Req 4.2", "NIST 800-53 SC-8", "CWE-319"]
|
|
1274
|
+
},
|
|
1275
|
+
"DB_SQL_INJECTION_RISK": {
|
|
1276
|
+
pattern: "db.query('SELECT * FROM users WHERE id = ' + req.params.id)",
|
|
1277
|
+
fix: "db.query('SELECT * FROM users WHERE id = $1', [req.params.id])",
|
|
1278
|
+
explanation: "Never concatenate user input into SQL. Use parameterized queries or ORM query builders.",
|
|
1279
|
+
references: ["OWASP Top 10 A03:2021", "CWE-89", "NIST 800-53 SI-10"]
|
|
1280
|
+
},
|
|
1281
|
+
"GRAPHQL_INTROSPECTION_ENABLED": {
|
|
1282
|
+
pattern: "new ApolloServer({ introspection: true })",
|
|
1283
|
+
fix: "new ApolloServer({ introspection: process.env.NODE_ENV !== 'production' })",
|
|
1284
|
+
explanation: "GraphQL introspection exposes the full schema to attackers. Disable it in non-dev environments.",
|
|
1285
|
+
references: ["OWASP API Security Top 10 API8:2023", "CWE-200"]
|
|
1286
|
+
},
|
|
1287
|
+
"GRAPHQL_NO_DEPTH_LIMIT": {
|
|
1288
|
+
pattern: "new ApolloServer({ schema })",
|
|
1289
|
+
fix: "import depthLimit from 'graphql-depth-limit';\nnew ApolloServer({ schema, validationRules: [depthLimit(10)] })",
|
|
1290
|
+
explanation: "Without depth limiting, attackers can send deeply nested queries to exhaust server resources.",
|
|
1291
|
+
references: ["OWASP API Security Top 10 API4:2023"]
|
|
1292
|
+
},
|
|
1293
|
+
"K8S_PRIVILEGED_CONTAINER": {
|
|
1294
|
+
pattern: "securityContext:\n privileged: true",
|
|
1295
|
+
fix: "securityContext:\n privileged: false\n allowPrivilegeEscalation: false\n runAsNonRoot: true\n capabilities:\n drop: [\"ALL\"]",
|
|
1296
|
+
explanation: "Privileged containers have unrestricted access to the host kernel. Remove privileged mode and drop all capabilities.",
|
|
1297
|
+
references: ["CIS Kubernetes Benchmark 5.2.1", "NIST 800-190"]
|
|
1298
|
+
},
|
|
1299
|
+
"K8S_NO_SECURITY_CONTEXT": {
|
|
1300
|
+
pattern: "containers:\n - name: app\n image: myapp:1.0",
|
|
1301
|
+
fix: "containers:\n - name: app\n image: myapp:1.0\n securityContext:\n runAsNonRoot: true\n runAsUser: 1000\n readOnlyRootFilesystem: true\n allowPrivilegeEscalation: false\n capabilities:\n drop: [\"ALL\"]",
|
|
1302
|
+
explanation: "Always set a securityContext to enforce least-privilege container execution.",
|
|
1303
|
+
references: ["CIS Kubernetes Benchmark", "NIST 800-190", "OWASP Kubernetes Security Cheat Sheet"]
|
|
1304
|
+
},
|
|
1305
|
+
"DLP_REQUEST_BODY_LOGGED": {
|
|
1306
|
+
pattern: "console.log(req.body)",
|
|
1307
|
+
fix: "const { password, token, ...safeFields } = req.body;\nconsole.log({ requestId, safeFields })",
|
|
1308
|
+
explanation: "Full request bodies may contain PII, passwords, or tokens. Log only allowlisted non-sensitive fields.",
|
|
1309
|
+
references: ["GDPR Article 5", "HIPAA 45 CFR 164.312", "CWE-532"]
|
|
1310
|
+
},
|
|
1311
|
+
"DLP_STACK_TRACE_IN_RESPONSE": {
|
|
1312
|
+
pattern: "res.json({ error: err.message, stack: err.stack })",
|
|
1313
|
+
fix: "logger.error({ err, requestId }); // log internally\nres.json({ error: 'An internal error occurred', requestId })",
|
|
1314
|
+
explanation: "Stack traces in API responses disclose internal architecture to attackers (CWE-209). Log internally, return only a safe message.",
|
|
1315
|
+
references: ["CWE-209", "OWASP Top 10 A05:2021", "PCI DSS 4.0 Req 6.2.4"]
|
|
1316
|
+
},
|
|
1317
|
+
"API_TENANT_ID_FROM_INPUT": {
|
|
1318
|
+
pattern: "const tenantId = req.query.tenantId",
|
|
1319
|
+
fix: "const tenantId = req.auth.tenantId // from verified JWT claims",
|
|
1320
|
+
explanation: "Tenant ID must come from the authenticated session/JWT claims. User-supplied tenant IDs allow cross-tenant data access.",
|
|
1321
|
+
references: ["OWASP API Security Top 10 API1:2023", "CWE-639"]
|
|
1322
|
+
},
|
|
1323
|
+
"LOCKFILE_MISSING": {
|
|
1324
|
+
pattern: "# No package-lock.json in repository",
|
|
1325
|
+
fix: "npm install # generates package-lock.json\ngit add package-lock.json\ngit commit -m 'chore: add lockfile'",
|
|
1326
|
+
explanation: "Without a lockfile, npm install resolves the latest matching version on each run, opening the door to supply chain attacks.",
|
|
1327
|
+
references: ["SLSA L1", "NIST 800-218 PS-3", "CWE-829"]
|
|
1328
|
+
},
|
|
1329
|
+
"DEP_FLOATING_VERSION": {
|
|
1330
|
+
pattern: "\"some-package\": \"^1.0.0\"",
|
|
1331
|
+
fix: "\"some-package\": \"1.2.3\" // exact pin\n// or use npm shrinkwrap / lockfile",
|
|
1332
|
+
explanation: "Floating version ranges allow unexpected major/minor updates that may introduce vulnerabilities or breaking changes.",
|
|
1333
|
+
references: ["SLSA L1", "OWASP Top 10 A06:2021"]
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1336
|
+
const GenerateRemediationsParams = {
|
|
1337
|
+
findings: z.array(z.object({
|
|
1338
|
+
id: z.string(),
|
|
1339
|
+
title: z.string(),
|
|
1340
|
+
severity: z.string(),
|
|
1341
|
+
files: z.array(z.string()).optional(),
|
|
1342
|
+
evidence: z.array(z.string()).optional()
|
|
1343
|
+
})).describe("Findings array from a gate run result.")
|
|
1344
|
+
};
|
|
1345
|
+
const GenerateRemediationsSchema = z.object(GenerateRemediationsParams);
|
|
1346
|
+
tool("security.generate_remediations", "Maps each gate finding to a specific, actionable code-level remediation template. Called automatically after every gate FAIL. Returns ready-to-apply fix templates keyed by finding ID.", GenerateRemediationsParams, safeTool(async (args, _extra) => {
|
|
1347
|
+
const { findings } = GenerateRemediationsSchema.parse(args);
|
|
1348
|
+
const result = {};
|
|
1349
|
+
for (const finding of findings) {
|
|
1350
|
+
// Try exact match first, then prefix match
|
|
1351
|
+
const exactMatch = REMEDIATION_MAP[finding.id];
|
|
1352
|
+
const prefixMatch = Object.keys(REMEDIATION_MAP).find((k) => finding.id.startsWith(k) || k.startsWith(finding.id));
|
|
1353
|
+
result[finding.id] = {
|
|
1354
|
+
finding,
|
|
1355
|
+
remediation: exactMatch ?? (prefixMatch ? REMEDIATION_MAP[prefixMatch] : null)
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
const withRemediation = Object.values(result).filter((r) => r.remediation !== null).length;
|
|
1359
|
+
const without = findings.length - withRemediation;
|
|
1360
|
+
return asTextResponse({
|
|
1361
|
+
summary: { total: findings.length, withRemediation, withoutRemediationTemplate: without },
|
|
1362
|
+
remediations: result
|
|
1363
|
+
});
|
|
1364
|
+
}));
|
|
1365
|
+
// ---------------------------------------------------------------------------
|
|
871
1366
|
// MCP Prompts capability
|
|
872
1367
|
// ---------------------------------------------------------------------------
|
|
873
|
-
server.prompt("security-engineer", "Activate the security-mcp system prompt.
|
|
1368
|
+
server.prompt("security-engineer", "Activate the security-mcp system prompt. Operating ratio: 90% fixing, 10% advisory — writes the fix, implements the control, enforces the policy. Does NOT list vulnerabilities and walk away. Applies OWASP, MITRE ATT&CK, NIST 800-53, Zero Trust, PCI DSS, SOC 2, and ISO 27001 to every code and architecture decision.", async () => ({
|
|
874
1369
|
messages: [
|
|
875
1370
|
{
|
|
876
1371
|
role: "user",
|
package/dist/repo/search.js
CHANGED
|
@@ -42,7 +42,19 @@ function scanLines(file, lines, opts, re, matches) {
|
|
|
42
42
|
export async function searchRepo(opts) {
|
|
43
43
|
const files = await fg(["**/*.*"], {
|
|
44
44
|
dot: true,
|
|
45
|
-
ignore: [
|
|
45
|
+
ignore: [
|
|
46
|
+
"**/node_modules/**",
|
|
47
|
+
"**/.git/**",
|
|
48
|
+
"**/dist/**",
|
|
49
|
+
"**/.claude/**",
|
|
50
|
+
// Exclude tool-internal files — they contain detection patterns and remediation
|
|
51
|
+
// examples that would trigger their own scanners (false positives in self-scan).
|
|
52
|
+
// When deployed as a package, these live in node_modules and are ignored naturally.
|
|
53
|
+
"src/gate/**",
|
|
54
|
+
"src/mcp/**",
|
|
55
|
+
"src/cli/**",
|
|
56
|
+
"prompts/**"
|
|
57
|
+
]
|
|
46
58
|
});
|
|
47
59
|
const re = opts.isRegex ? compileUserRegex(opts.query) : null;
|
|
48
60
|
const matches = [];
|