security-mcp 1.1.3 → 1.3.1
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.md +164 -185
- package/defaults/checklists/ai.json +20 -1
- package/defaults/checklists/api.json +35 -1
- package/defaults/checklists/infra.json +34 -1
- package/defaults/checklists/mobile.json +23 -1
- package/defaults/checklists/payments.json +15 -1
- package/defaults/checklists/web.json +11 -1
- package/defaults/control-catalog.json +200 -0
- package/defaults/security-policy.json +2 -2
- package/dist/cli/index.js +82 -5
- package/dist/cli/install.js +36 -6
- package/dist/cli/onboarding.js +6 -0
- package/dist/gate/baseline.js +82 -7
- package/dist/gate/catalog.js +10 -2
- package/dist/gate/checks/ai.js +757 -39
- package/dist/gate/checks/auth-deep.js +935 -0
- package/dist/gate/checks/business-logic.js +751 -0
- package/dist/gate/checks/ci-pipeline.js +399 -4
- package/dist/gate/checks/crypto.js +423 -2
- package/dist/gate/checks/dependencies.js +571 -15
- package/dist/gate/checks/graphql.js +201 -19
- package/dist/gate/checks/infra.js +246 -1
- package/dist/gate/checks/injection-deep.js +848 -0
- package/dist/gate/checks/k8s.js +114 -1
- package/dist/gate/checks/mobile-android.js +917 -3
- package/dist/gate/checks/mobile-ios.js +797 -5
- package/dist/gate/checks/required-artifacts.js +194 -0
- package/dist/gate/checks/runtime.js +178 -0
- package/dist/gate/checks/secrets.js +244 -13
- package/dist/gate/checks/supply-chain-deep.js +787 -0
- package/dist/gate/checks/web-nextjs.js +572 -48
- package/dist/gate/diff.js +17 -5
- package/dist/gate/evidence.js +8 -1
- package/dist/gate/exceptions.js +131 -9
- package/dist/gate/policy.js +282 -129
- package/dist/mcp/audit-chain.js +122 -28
- package/dist/mcp/auth.js +169 -0
- package/dist/mcp/learning.js +129 -4
- package/dist/mcp/model-router.js +158 -21
- package/dist/mcp/orchestration.js +186 -51
- package/dist/mcp/server.js +608 -94
- package/dist/repo/fs.js +24 -1
- package/dist/repo/search.js +31 -6
- package/dist/review/store.js +52 -1
- package/package.json +7 -7
- package/prompts/SECURITY_PROMPT.md +73 -0
- package/skills/_TEMPLATE/SKILL.md +99 -0
- package/skills/advanced-dos-tester/SKILL.md +109 -0
- package/skills/agentic-loop-exploiter/SKILL.md +368 -0
- package/skills/ai-llm-redteam/SKILL.md +104 -0
- package/skills/ai-model-supply-chain-agent/SKILL.md +103 -0
- package/skills/algorithm-implementation-reviewer/SKILL.md +98 -0
- package/skills/android-penetration-tester/SKILL.md +455 -46
- package/skills/anti-replay-tester/SKILL.md +106 -0
- package/skills/appsec-code-auditor/SKILL.md +120 -0
- package/skills/artifact-integrity-analyst/SKILL.md +441 -0
- package/skills/attack-navigator/SKILL.md +467 -8
- package/skills/auth-session-hacker/SKILL.md +128 -0
- package/skills/aws-penetration-tester/SKILL.md +456 -0
- package/skills/azure-penetration-tester/SKILL.md +490 -3
- package/skills/binary-auth-validator/SKILL.md +111 -0
- package/skills/bot-detection-specialist/SKILL.md +109 -0
- package/skills/business-logic-attacker/SKILL.md +231 -0
- package/skills/capec-code-mapper/SKILL.md +84 -0
- package/skills/cert-pin-rotation-specialist/SKILL.md +112 -0
- package/skills/cicd-pipeline-hijacker/SKILL.md +405 -0
- package/skills/ciso-orchestrator/SKILL.md +454 -43
- package/skills/cloud-infra-specialist/SKILL.md +118 -0
- package/skills/compliance-gap-analyst/SKILL.md +422 -0
- package/skills/compliance-grc/SKILL.md +85 -0
- package/skills/compliance-lifecycle-tracker/SKILL.md +84 -0
- package/skills/credential-stuffing-specialist/SKILL.md +102 -0
- package/skills/crypto-pki-specialist/SKILL.md +87 -0
- package/skills/csa-ccm-mapper/SKILL.md +84 -0
- package/skills/csf2-governance-mapper/SKILL.md +84 -0
- package/skills/deep-link-fuzzer/SKILL.md +109 -0
- package/skills/dependency-confusion-attacker/SKILL.md +415 -0
- package/skills/device-integrity-aggregator/SKILL.md +108 -0
- package/skills/dos-resilience-tester/SKILL.md +97 -0
- package/skills/dread-scorer/SKILL.md +84 -0
- package/skills/egress-policy-enforcer/SKILL.md +99 -0
- package/skills/evidence-collector/SKILL.md +98 -0
- package/skills/file-upload-attacker/SKILL.md +109 -0
- package/skills/gcp-penetration-tester/SKILL.md +459 -2
- package/skills/git-history-secret-scanner/SKILL.md +106 -0
- package/skills/iam-privesc-graph-builder/SKILL.md +152 -0
- package/skills/incident-responder/SKILL.md +111 -0
- package/skills/injection-specialist/SKILL.md +131 -0
- package/skills/ios-security-auditor/SKILL.md +282 -0
- package/skills/json-ambiguity-tester/SKILL.md +0 -0
- package/skills/k8s-container-escaper/SKILL.md +384 -0
- package/skills/key-management-lifecycle-analyst/SKILL.md +98 -0
- package/skills/kill-switch-engineer/SKILL.md +102 -0
- package/skills/linddun-privacy-analyst/SKILL.md +102 -0
- package/skills/logic-race-fuzzer/SKILL.md +443 -0
- package/skills/mobile-api-network-attacker/SKILL.md +421 -0
- package/skills/mobile-binary-hardener/SKILL.md +102 -0
- package/skills/mobile-security-specialist/SKILL.md +85 -0
- package/skills/mobile-webview-auditor/SKILL.md +96 -0
- package/skills/model-extraction-attacker/SKILL.md +219 -0
- package/skills/multipart-abuse-tester/SKILL.md +84 -0
- package/skills/oauth-pkce-specialist/SKILL.md +104 -0
- package/skills/parser-exhaustion-tester/SKILL.md +142 -0
- package/skills/pentest-infra/SKILL.md +141 -0
- package/skills/pentest-social/SKILL.md +201 -0
- package/skills/pentest-team/SKILL.md +134 -0
- package/skills/pentest-web-api/SKILL.md +151 -0
- package/skills/privacy-flow-analyst/SKILL.md +234 -0
- package/skills/prompt-injection-specialist/SKILL.md +394 -0
- package/skills/quantum-migration-planner/SKILL.md +96 -0
- package/skills/rag-poisoning-specialist/SKILL.md +358 -0
- package/skills/registry-mirror-enforcer/SKILL.md +84 -0
- package/skills/rotation-validation-agent/SKILL.md +112 -0
- package/skills/samm-assessor/SKILL.md +85 -0
- package/skills/secrets-mask-bypass-tester/SKILL.md +100 -0
- package/skills/senior-security-engineer/SKILL.md +370 -2
- package/skills/serialization-memory-attacker/SKILL.md +332 -0
- package/skills/session-timeout-tester/SKILL.md +161 -0
- package/skills/slsa-level3-enforcer/SKILL.md +112 -0
- package/skills/slsa-provenance-enforcer/SKILL.md +102 -0
- package/skills/ssrf-detection-validator/SKILL.md +108 -0
- package/skills/step-up-auth-enforcer/SKILL.md +84 -0
- package/skills/stride-pasta-analyst/SKILL.md +420 -0
- package/skills/supply-chain-devsecops/SKILL.md +98 -0
- package/skills/threat-infrastructure-analyst/SKILL.md +84 -0
- package/skills/threat-modeler/SKILL.md +85 -0
- package/skills/tls-certificate-auditor/SKILL.md +573 -18
- package/skills/token-reuse-detector/SKILL.md +95 -0
- package/skills/trike-risk-modeler/SKILL.md +84 -0
- package/skills/unicode-homograph-tester/SKILL.md +84 -0
- package/skills/waf-rule-lifecycle-agent/SKILL.md +97 -0
- package/skills/webhook-security-tester/SKILL.md +102 -0
- package/skills/zero-trust-architect/SKILL.md +109 -0
|
@@ -5,8 +5,392 @@
|
|
|
5
5
|
import { sanitizeErrorMessage } from "../result.js";
|
|
6
6
|
import fg from "fast-glob";
|
|
7
7
|
import { readFileSafe } from "../../repo/fs.js";
|
|
8
|
-
|
|
8
|
+
// Pattern that identifies an active (non-commented) security gate invocation line.
|
|
9
|
+
const GATE_INVOCATION_RE = /npm run ci:pr-gate|npx security-mcp|security-mcp.*gate|security_gate|run_pr_gate/;
|
|
10
|
+
async function checkGateStepPresent(changedFiles) {
|
|
11
|
+
const findings = [];
|
|
12
|
+
try {
|
|
13
|
+
const workflowFiles = await fg([".github/workflows/*.yml", ".github/workflows/*.yaml"], {
|
|
14
|
+
dot: true,
|
|
15
|
+
ignore: ["**/node_modules/**", "**/.git/**"]
|
|
16
|
+
});
|
|
17
|
+
if (workflowFiles.length === 0) {
|
|
18
|
+
return findings;
|
|
19
|
+
}
|
|
20
|
+
let gateInvokedFile = null;
|
|
21
|
+
let gateDisabledFile = null;
|
|
22
|
+
for (const file of workflowFiles) {
|
|
23
|
+
let content;
|
|
24
|
+
try {
|
|
25
|
+
content = await readFileSafe(file);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const lines = content.split("\n");
|
|
31
|
+
// Check whether the file contains the gate invocation at all (commented or not)
|
|
32
|
+
const hasGatePattern = lines.some((line) => GATE_INVOCATION_RE.test(line));
|
|
33
|
+
if (!hasGatePattern)
|
|
34
|
+
continue;
|
|
35
|
+
// Distinguish active invocation from commented-out invocation
|
|
36
|
+
const activeLines = lines.filter((line) => GATE_INVOCATION_RE.test(line) && !/^\s*#/.test(line));
|
|
37
|
+
if (activeLines.length > 0) {
|
|
38
|
+
gateInvokedFile = file;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// Pattern present but every matching line is commented out
|
|
42
|
+
gateDisabledFile = file;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!gateInvokedFile && !gateDisabledFile) {
|
|
46
|
+
findings.push({
|
|
47
|
+
id: "GATE_STEP_ABSENT",
|
|
48
|
+
title: "No GitHub Actions workflow found that invokes the security gate",
|
|
49
|
+
severity: "HIGH",
|
|
50
|
+
files: [],
|
|
51
|
+
requiredActions: [
|
|
52
|
+
"No GitHub Actions workflow found that invokes the security gate (npm run ci:pr-gate). The gate may not be enforced on PRs.",
|
|
53
|
+
"Add a step `run: npm run ci:pr-gate` to your PR workflow (e.g. .github/workflows/security-gate.yml).",
|
|
54
|
+
"Without this step, HIGH/CRITICAL security findings will not block pull request merges."
|
|
55
|
+
]
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (gateDisabledFile) {
|
|
59
|
+
findings.push({
|
|
60
|
+
id: "GATE_STEP_DISABLED",
|
|
61
|
+
title: "The security gate step is present in the workflow but appears to be commented out or disabled",
|
|
62
|
+
severity: "CRITICAL",
|
|
63
|
+
files: [gateDisabledFile],
|
|
64
|
+
requiredActions: [
|
|
65
|
+
"The security gate step is present in the workflow but appears to be commented out or disabled.",
|
|
66
|
+
"Uncomment or re-enable the `npm run ci:pr-gate` step so it blocks PRs with HIGH/CRITICAL findings.",
|
|
67
|
+
"A disabled gate provides no protection — attackers can merge vulnerable code without review."
|
|
68
|
+
]
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// Check for self-modification: security-gate.yml is being modified AND the gate step would be removed
|
|
72
|
+
const gateWorkflowChanged = changedFiles.some((f) => f.includes("security-gate.yml"));
|
|
73
|
+
if (gateWorkflowChanged && gateDisabledFile && gateDisabledFile.includes("security-gate.yml")) {
|
|
74
|
+
findings.push({
|
|
75
|
+
id: "GATE_WORKFLOW_SELF_MODIFICATION",
|
|
76
|
+
title: "security-gate.yml is being modified and the gate step is disabled",
|
|
77
|
+
severity: "CRITICAL",
|
|
78
|
+
files: ["security-gate.yml"],
|
|
79
|
+
requiredActions: [
|
|
80
|
+
"security-gate.yml is being modified in this PR and the gate invocation step appears to be commented out or removed.",
|
|
81
|
+
"This self-modification could bypass the security gate for all future PRs.",
|
|
82
|
+
"Restore the `npm run ci:pr-gate` step before merging this change."
|
|
83
|
+
]
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
console.warn("[checkGateStepPresent] Internal error:", sanitizeErrorMessage(err instanceof Error ? err.message : String(err)));
|
|
89
|
+
}
|
|
90
|
+
return findings;
|
|
91
|
+
}
|
|
92
|
+
async function checkWorkflowInjection() {
|
|
93
|
+
const findings = [];
|
|
94
|
+
try {
|
|
95
|
+
const workflowFiles = await fg(["**/.github/workflows/*.yml", "**/.github/workflows/*.yaml"], {
|
|
96
|
+
dot: true,
|
|
97
|
+
ignore: ["**/node_modules/**", "**/.git/**"]
|
|
98
|
+
});
|
|
99
|
+
// Covers all attacker-controlled GitHub context tokens that can contain shell metacharacters:
|
|
100
|
+
// - github.event.{issue,pull_request,comment,review,discussion,inputs,release}.*
|
|
101
|
+
// - github.head_ref (attacker-controlled branch name on fork PRs)
|
|
102
|
+
// - github.ref_name (mutable, attacker-influenced on PRs)
|
|
103
|
+
// - github.actor (attacker-controlled username)
|
|
104
|
+
// - github.event.workflow_run.head_branch / head_commit.*
|
|
105
|
+
const injectionRe = /\$\{\{\s*(?:github\.event\.(?:issue|pull_request|comment|review|discussion|inputs|release|workflow_run)\.[a-z_.]+|github\.(?:head_ref|ref_name|actor))\s*\}\}/;
|
|
106
|
+
const flaggedFiles = [];
|
|
107
|
+
for (const file of workflowFiles) {
|
|
108
|
+
let content;
|
|
109
|
+
try {
|
|
110
|
+
content = await readFileSafe(file);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const lines = content.split("\n");
|
|
116
|
+
for (let i = 0; i < lines.length; i++) {
|
|
117
|
+
if (!injectionRe.test(lines[i]))
|
|
118
|
+
continue;
|
|
119
|
+
// Check if "run:" appears within the 5 lines before this match
|
|
120
|
+
const windowStart = Math.max(0, i - 5);
|
|
121
|
+
const contextLines = lines.slice(windowStart, i);
|
|
122
|
+
if (contextLines.some((l) => /run:/.test(l))) {
|
|
123
|
+
flaggedFiles.push(file);
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (flaggedFiles.length > 0) {
|
|
129
|
+
findings.push({
|
|
130
|
+
id: "CI_WORKFLOW_INJECTION",
|
|
131
|
+
title: "GitHub Actions workflow uses github.event.* user input in run: step — workflow injection RCE",
|
|
132
|
+
severity: "CRITICAL",
|
|
133
|
+
files: flaggedFiles.slice(0, 10),
|
|
134
|
+
requiredActions: [
|
|
135
|
+
"GitHub Actions workflow uses github.event.* user input in run: step — workflow injection RCE (ATT&CK T1059, GHSL-2021-1167)",
|
|
136
|
+
"Never interpolate ${{ github.event.* }} directly into shell run: steps — pass values via environment variables instead (`env: VAL: ${{ github.event.issue.title }}`) and reference $VAL in the shell.",
|
|
137
|
+
"An attacker can craft a title/body/branch containing shell metacharacters to achieve arbitrary code execution with the runner's permissions."
|
|
138
|
+
]
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
console.warn("[checkWorkflowInjection] Internal error:", sanitizeErrorMessage(err instanceof Error ? err.message : String(err)));
|
|
144
|
+
}
|
|
145
|
+
return findings;
|
|
146
|
+
}
|
|
147
|
+
async function checkCiCachePoisoning() {
|
|
9
148
|
const findings = [];
|
|
149
|
+
try {
|
|
150
|
+
const workflowFiles = await fg(["**/.github/workflows/*.yml", "**/.github/workflows/*.yaml"], {
|
|
151
|
+
dot: true,
|
|
152
|
+
ignore: ["**/node_modules/**", "**/.git/**"]
|
|
153
|
+
});
|
|
154
|
+
const cacheActionRe = /uses:\s+actions\/cache/;
|
|
155
|
+
const poisonKeyRe = /key:.*\$\{\{.*github\.(?:head_ref|event\.pull_request\.head)/;
|
|
156
|
+
const flaggedFiles = [];
|
|
157
|
+
for (const file of workflowFiles) {
|
|
158
|
+
let content;
|
|
159
|
+
try {
|
|
160
|
+
content = await readFileSafe(file);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const lines = content.split("\n");
|
|
166
|
+
for (let i = 0; i < lines.length; i++) {
|
|
167
|
+
if (!cacheActionRe.test(lines[i]))
|
|
168
|
+
continue;
|
|
169
|
+
const windowEnd = Math.min(lines.length, i + 20);
|
|
170
|
+
const contextLines = lines.slice(i, windowEnd);
|
|
171
|
+
if (contextLines.some((l) => poisonKeyRe.test(l))) {
|
|
172
|
+
flaggedFiles.push(file);
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (flaggedFiles.length > 0) {
|
|
178
|
+
findings.push({
|
|
179
|
+
id: "CI_CACHE_POISONING",
|
|
180
|
+
title: "CI cache key includes attacker-controlled branch name — cache poisoning risk",
|
|
181
|
+
severity: "HIGH",
|
|
182
|
+
files: flaggedFiles.slice(0, 10),
|
|
183
|
+
requiredActions: [
|
|
184
|
+
"CI cache key includes attacker-controlled branch name — cache poisoning injects malicious build artifacts (ATT&CK T1195.002)",
|
|
185
|
+
"Do not use `github.head_ref` or `github.event.pull_request.head.*` in cache keys — an attacker can craft a branch name to collide with another PR's cache.",
|
|
186
|
+
"Use only trusted, non-user-controlled values in cache keys (e.g. `github.ref`, `hashFiles(...)`)."
|
|
187
|
+
]
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
console.warn("[checkCiCachePoisoning] Internal error:", sanitizeErrorMessage(err instanceof Error ? err.message : String(err)));
|
|
193
|
+
}
|
|
194
|
+
return findings;
|
|
195
|
+
}
|
|
196
|
+
async function checkDownloadArtifactNoVerify() {
|
|
197
|
+
const findings = [];
|
|
198
|
+
try {
|
|
199
|
+
const workflowFiles = await fg(["**/.github/workflows/*.yml", "**/.github/workflows/*.yaml"], {
|
|
200
|
+
dot: true,
|
|
201
|
+
ignore: ["**/node_modules/**", "**/.git/**"]
|
|
202
|
+
});
|
|
203
|
+
const downloadRe = /uses:\s+(?:actions\/download-artifact|dawidd6\/action-download-artifact)/;
|
|
204
|
+
const verifyRe = /sha256|signature|cosign|sigstore|verify/i;
|
|
205
|
+
const flaggedFiles = [];
|
|
206
|
+
for (const file of workflowFiles) {
|
|
207
|
+
let content;
|
|
208
|
+
try {
|
|
209
|
+
content = await readFileSafe(file);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const lines = content.split("\n");
|
|
215
|
+
for (let i = 0; i < lines.length; i++) {
|
|
216
|
+
if (!downloadRe.test(lines[i]))
|
|
217
|
+
continue;
|
|
218
|
+
const windowEnd = Math.min(lines.length, i + 10);
|
|
219
|
+
const contextLines = lines.slice(i, windowEnd);
|
|
220
|
+
if (!contextLines.some((l) => verifyRe.test(l))) {
|
|
221
|
+
flaggedFiles.push(file);
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (flaggedFiles.length > 0) {
|
|
227
|
+
findings.push({
|
|
228
|
+
id: "CI_ARTIFACT_NO_VERIFY",
|
|
229
|
+
title: "CI downloads build artifact without integrity verification",
|
|
230
|
+
severity: "HIGH",
|
|
231
|
+
files: flaggedFiles.slice(0, 10),
|
|
232
|
+
requiredActions: [
|
|
233
|
+
"CI downloads build artifact without integrity verification — artifact poisoning risk (ATT&CK T1195.002)",
|
|
234
|
+
"After downloading an artifact, verify its SHA-256 checksum or use Sigstore/cosign attestations before executing it.",
|
|
235
|
+
"An unverified artifact from a compromised or malicious workflow run can silently introduce backdoors into the build."
|
|
236
|
+
]
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
console.warn("[checkDownloadArtifactNoVerify] Internal error:", sanitizeErrorMessage(err instanceof Error ? err.message : String(err)));
|
|
242
|
+
}
|
|
243
|
+
return findings;
|
|
244
|
+
}
|
|
245
|
+
async function checkGithubTokenWriteAll() {
|
|
246
|
+
const findings = [];
|
|
247
|
+
try {
|
|
248
|
+
const workflowFiles = await fg(["**/.github/workflows/*.yml", "**/.github/workflows/*.yaml"], {
|
|
249
|
+
dot: true,
|
|
250
|
+
ignore: ["**/node_modules/**", "**/.git/**"]
|
|
251
|
+
});
|
|
252
|
+
const writePermRe = /permissions:\s*write-all|packages:\s*write|contents:\s*write|pull-requests:\s*write/;
|
|
253
|
+
const prTriggerRe = /pull_request(?:_target)?:/;
|
|
254
|
+
const flaggedFiles = [];
|
|
255
|
+
for (const file of workflowFiles) {
|
|
256
|
+
let content;
|
|
257
|
+
try {
|
|
258
|
+
content = await readFileSafe(file);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (writePermRe.test(content) && prTriggerRe.test(content)) {
|
|
264
|
+
flaggedFiles.push(file);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (flaggedFiles.length > 0) {
|
|
268
|
+
findings.push({
|
|
269
|
+
id: "CI_GITHUB_TOKEN_WRITE_ALL",
|
|
270
|
+
title: "GITHUB_TOKEN granted write permissions in workflow triggered by external PRs",
|
|
271
|
+
severity: "HIGH",
|
|
272
|
+
files: flaggedFiles.slice(0, 10),
|
|
273
|
+
requiredActions: [
|
|
274
|
+
"GITHUB_TOKEN granted write permissions in workflow triggered by external PRs — token theft enables repo write (ATT&CK T1552.001)",
|
|
275
|
+
"Restrict permissions to the minimum required scopes; avoid `write-all` on workflows that process untrusted pull requests.",
|
|
276
|
+
"Use `permissions: read-all` as the default and elevate only the specific scopes needed (e.g. `issues: write`)."
|
|
277
|
+
]
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
console.warn("[checkGithubTokenWriteAll] Internal error:", sanitizeErrorMessage(err instanceof Error ? err.message : String(err)));
|
|
283
|
+
}
|
|
284
|
+
return findings;
|
|
285
|
+
}
|
|
286
|
+
async function checkForkSecretExposure() {
|
|
287
|
+
const findings = [];
|
|
288
|
+
try {
|
|
289
|
+
const workflowFiles = await fg(["**/.github/workflows/*.yml", "**/.github/workflows/*.yaml"], {
|
|
290
|
+
dot: true,
|
|
291
|
+
ignore: ["**/node_modules/**", "**/.git/**"]
|
|
292
|
+
});
|
|
293
|
+
const secretsRe = /secrets\./;
|
|
294
|
+
const flaggedFiles = [];
|
|
295
|
+
for (const file of workflowFiles) {
|
|
296
|
+
let content;
|
|
297
|
+
try {
|
|
298
|
+
content = await readFileSafe(file);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
// Match "pull_request:" but NOT "pull_request_target:"
|
|
304
|
+
if (/^\s*pull_request:/m.test(content) && secretsRe.test(content)) {
|
|
305
|
+
flaggedFiles.push(file);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (flaggedFiles.length > 0) {
|
|
309
|
+
findings.push({
|
|
310
|
+
id: "CI_FORK_SECRET_EXPOSURE",
|
|
311
|
+
title: "Secrets referenced in pull_request-triggered workflow — exposed to fork PR contributors",
|
|
312
|
+
severity: "CRITICAL",
|
|
313
|
+
files: flaggedFiles.slice(0, 10),
|
|
314
|
+
requiredActions: [
|
|
315
|
+
"Secrets referenced in pull_request-triggered workflow — exposed to fork PR contributors (ATT&CK T1552.001)",
|
|
316
|
+
"Workflows triggered by `pull_request` from forks do not have access to secrets by default, but referencing them signals intent and may expose them in other contexts.",
|
|
317
|
+
"Move secret-dependent steps to a separate workflow triggered by `pull_request_target` with explicit trust checks, or use environment protection rules to gate secret access."
|
|
318
|
+
]
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
console.warn("[checkForkSecretExposure] Internal error:", sanitizeErrorMessage(err instanceof Error ? err.message : String(err)));
|
|
324
|
+
}
|
|
325
|
+
return findings;
|
|
326
|
+
}
|
|
327
|
+
async function checkNpmIgnoreScriptsCi() {
|
|
328
|
+
const findings = [];
|
|
329
|
+
try {
|
|
330
|
+
const workflowFiles = await fg(["**/.github/workflows/*.yml", "**/.github/workflows/*.yaml"], {
|
|
331
|
+
dot: true,
|
|
332
|
+
ignore: ["**/node_modules/**", "**/.git/**"]
|
|
333
|
+
});
|
|
334
|
+
// Check whether .npmrc already sets ignore-scripts=true
|
|
335
|
+
let npmrcDisablesScripts = false;
|
|
336
|
+
const npmrcFiles = await fg([".npmrc", "**/.npmrc"], {
|
|
337
|
+
dot: true,
|
|
338
|
+
ignore: ["**/node_modules/**", "**/.git/**"]
|
|
339
|
+
});
|
|
340
|
+
for (const rc of npmrcFiles) {
|
|
341
|
+
let rcContent;
|
|
342
|
+
try {
|
|
343
|
+
rcContent = await readFileSafe(rc);
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (/^\s*ignore-scripts\s*=\s*true/m.test(rcContent)) {
|
|
349
|
+
npmrcDisablesScripts = true;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (npmrcDisablesScripts) {
|
|
354
|
+
return findings;
|
|
355
|
+
}
|
|
356
|
+
// npm install/ci without --ignore-scripts (and not already covered by .npmrc)
|
|
357
|
+
const npmBareRe = /npm\s+(?:install|ci)(?!.*--ignore-scripts)/;
|
|
358
|
+
const flaggedFiles = [];
|
|
359
|
+
for (const file of workflowFiles) {
|
|
360
|
+
let content;
|
|
361
|
+
try {
|
|
362
|
+
content = await readFileSafe(file);
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (npmBareRe.test(content)) {
|
|
368
|
+
flaggedFiles.push(file);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (flaggedFiles.length > 0) {
|
|
372
|
+
findings.push({
|
|
373
|
+
id: "CI_NPM_MISSING_IGNORE_SCRIPTS",
|
|
374
|
+
title: "npm install/ci in CI without --ignore-scripts",
|
|
375
|
+
severity: "MEDIUM",
|
|
376
|
+
files: flaggedFiles.slice(0, 10),
|
|
377
|
+
requiredActions: [
|
|
378
|
+
"npm install/ci in CI without --ignore-scripts — postinstall scripts execute automatically (ATT&CK T1195.001)",
|
|
379
|
+
"Add `--ignore-scripts` to all `npm install` / `npm ci` invocations in CI, or set `ignore-scripts=true` in .npmrc.",
|
|
380
|
+
"Malicious or compromised dependencies can use postinstall/preinstall lifecycle scripts to exfiltrate secrets or modify build artifacts."
|
|
381
|
+
]
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
console.warn("[checkNpmIgnoreScriptsCi] Internal error:", sanitizeErrorMessage(err instanceof Error ? err.message : String(err)));
|
|
387
|
+
}
|
|
388
|
+
return findings;
|
|
389
|
+
}
|
|
390
|
+
export async function runCiPipelineChecks(_opts) {
|
|
391
|
+
const findings = [
|
|
392
|
+
...await checkGateStepPresent(_opts.changedFiles)
|
|
393
|
+
];
|
|
10
394
|
try {
|
|
11
395
|
const workflowFiles = await fg([".github/workflows/*.yml", ".github/workflows/*.yaml"], {
|
|
12
396
|
dot: true,
|
|
@@ -29,15 +413,17 @@ export async function runCiPipelineChecks(_opts) {
|
|
|
29
413
|
continue;
|
|
30
414
|
}
|
|
31
415
|
// Check 1: Third-party actions not pinned to a full 40-char SHA
|
|
32
|
-
// Matches `uses: owner/repo@tag` but NOT `uses: owner/repo@<
|
|
416
|
+
// Matches `uses: owner/repo@tag` but NOT `uses: owner/repo@<exactly 40 hex chars>`
|
|
33
417
|
// Also skip `uses: ./.github/actions/` (local actions are fine)
|
|
34
418
|
const actionLines = content.split("\n").filter((line) => /uses:\s+[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+@/.test(line));
|
|
35
419
|
const unpinnedActions = actionLines.filter((line) => {
|
|
36
420
|
// Skip local actions
|
|
37
421
|
if (/uses:\s+\.\//.test(line))
|
|
38
422
|
return false;
|
|
39
|
-
// Flag anything not pinned to a 40-char hex SHA
|
|
40
|
-
|
|
423
|
+
// Flag anything not pinned to EXACTLY a 40-char hex SHA.
|
|
424
|
+
// The negative lookahead (?![0-9a-f]) prevents 41+ char hex strings from
|
|
425
|
+
// being mistakenly treated as valid SHA-1 digests.
|
|
426
|
+
return !/uses:\s+[a-zA-Z0-9_.\-/]+@[0-9a-f]{40}(?![0-9a-f])/.test(line);
|
|
41
427
|
});
|
|
42
428
|
if (unpinnedActions.length > 0) {
|
|
43
429
|
unpinnedFiles.push(file);
|
|
@@ -131,5 +517,14 @@ export async function runCiPipelineChecks(_opts) {
|
|
|
131
517
|
catch (err) {
|
|
132
518
|
console.warn("[runCiPipelineChecks] Internal error:", sanitizeErrorMessage(err instanceof Error ? err.message : String(err)));
|
|
133
519
|
}
|
|
520
|
+
const additional = await Promise.all([
|
|
521
|
+
checkWorkflowInjection(),
|
|
522
|
+
checkCiCachePoisoning(),
|
|
523
|
+
checkDownloadArtifactNoVerify(),
|
|
524
|
+
checkGithubTokenWriteAll(),
|
|
525
|
+
checkForkSecretExposure(),
|
|
526
|
+
checkNpmIgnoreScriptsCi()
|
|
527
|
+
]);
|
|
528
|
+
findings.push(...additional.flat());
|
|
134
529
|
return findings;
|
|
135
530
|
}
|