security-mcp 1.3.1 → 1.3.4
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 +286 -887
- package/defaults/cloud-controls/aws.json +10712 -0
- package/defaults/cloud-controls/azure.json +7201 -0
- package/defaults/cloud-controls/gcp.json +4061 -0
- package/defaults/control-catalog.json +24 -0
- package/dist/ci/pr-gate.js +22 -5
- package/dist/cli/index.js +73 -2
- package/dist/cli/install.js +4 -55
- package/dist/cli/onboarding.js +18 -10
- package/dist/gate/checks/agentic-instructions.js +515 -0
- package/dist/gate/checks/ai-governance.js +132 -0
- package/dist/gate/checks/ai.js +1 -1
- package/dist/gate/checks/cloud-controls.js +69 -0
- package/dist/gate/checks/crypto.js +1 -1
- package/dist/gate/checks/data-platform.js +954 -0
- package/dist/gate/checks/dependencies.js +14 -3
- package/dist/gate/checks/docker-deep.js +1236 -0
- package/dist/gate/checks/gitops.js +724 -0
- package/dist/gate/checks/iac.js +1230 -0
- package/dist/gate/checks/k8s.js +841 -1
- package/dist/gate/checks/secrets.js +49 -37
- package/dist/gate/cloud-controls/apply.js +115 -0
- package/dist/gate/cloud-controls/bicep.js +36 -0
- package/dist/gate/cloud-controls/cfn.js +125 -0
- package/dist/gate/cloud-controls/detect.js +104 -0
- package/dist/gate/cloud-controls/hcl.js +140 -0
- package/dist/gate/cloud-controls/types.js +87 -0
- package/dist/gate/exceptions.js +78 -7
- package/dist/gate/findings.js +15 -2
- package/dist/gate/policy.js +40 -3
- package/dist/gate/threat-intel.js +6 -0
- package/dist/mcp/audit-chain.js +9 -0
- package/dist/mcp/model-router.js +3 -3
- package/dist/mcp/orchestration.js +194 -41
- package/dist/mcp/server.js +124 -17
- package/dist/mcp/tool-audit.js +193 -0
- package/dist/repo/fs.js +14 -1
- package/dist/review/store.js +4 -2
- package/dist/tests/run.js +124 -1
- package/package.json +6 -4
- package/skills/advanced-dos-tester/SKILL.md +9 -0
- package/skills/agentic-instruction-auditor/SKILL.md +111 -0
- package/skills/agentic-loop-exploiter/SKILL.md +9 -0
- package/skills/ai-llm-redteam/SKILL.md +9 -0
- package/skills/ai-model-supply-chain-agent/SKILL.md +9 -0
- package/skills/algorithm-implementation-reviewer/SKILL.md +9 -0
- package/skills/android-penetration-tester/SKILL.md +9 -0
- package/skills/anti-replay-tester/SKILL.md +9 -0
- package/skills/appsec-code-auditor/SKILL.md +9 -0
- package/skills/artifact-integrity-analyst/SKILL.md +9 -0
- package/skills/attack-navigator/SKILL.md +9 -0
- package/skills/auth-session-hacker/SKILL.md +9 -0
- package/skills/aws-penetration-tester/SKILL.md +54 -0
- package/skills/azure-penetration-tester/SKILL.md +52 -0
- package/skills/binary-auth-validator/SKILL.md +9 -0
- package/skills/bot-detection-specialist/SKILL.md +9 -0
- package/skills/business-logic-attacker/SKILL.md +9 -0
- package/skills/capec-code-mapper/SKILL.md +9 -0
- package/skills/cert-pin-rotation-specialist/SKILL.md +9 -0
- package/skills/cicd-pipeline-hijacker/SKILL.md +9 -0
- package/skills/ciso-orchestrator/SKILL.md +11 -0
- package/skills/cloud-infra-specialist/SKILL.md +9 -0
- package/skills/compliance-gap-analyst/SKILL.md +9 -0
- package/skills/compliance-grc/SKILL.md +9 -0
- package/skills/compliance-lifecycle-tracker/SKILL.md +9 -0
- package/skills/container-hardening-auditor/SKILL.md +125 -0
- package/skills/credential-stuffing-specialist/SKILL.md +9 -0
- package/skills/crypto-pki-specialist/SKILL.md +9 -0
- package/skills/csa-ccm-mapper/SKILL.md +9 -0
- package/skills/csf2-governance-mapper/SKILL.md +9 -0
- package/skills/data-platform-auditor/SKILL.md +125 -0
- package/skills/deep-link-fuzzer/SKILL.md +9 -0
- package/skills/dependency-confusion-attacker/SKILL.md +9 -0
- package/skills/device-integrity-aggregator/SKILL.md +9 -0
- package/skills/dos-resilience-tester/SKILL.md +9 -0
- package/skills/dread-scorer/SKILL.md +9 -0
- package/skills/egress-policy-enforcer/SKILL.md +9 -0
- package/skills/evidence-collector/SKILL.md +9 -0
- package/skills/file-upload-attacker/SKILL.md +9 -0
- package/skills/gcp-penetration-tester/SKILL.md +51 -0
- package/skills/git-history-secret-scanner/SKILL.md +9 -0
- package/skills/gitops-delivery-auditor/SKILL.md +120 -0
- package/skills/iac-security-auditor/SKILL.md +125 -0
- package/skills/iam-privesc-graph-builder/SKILL.md +9 -0
- package/skills/incident-responder/SKILL.md +9 -0
- package/skills/injection-specialist/SKILL.md +9 -0
- package/skills/ios-security-auditor/SKILL.md +9 -0
- package/skills/json-ambiguity-tester/SKILL.md +0 -0
- package/skills/k8s-container-escaper/SKILL.md +22 -0
- package/skills/key-management-lifecycle-analyst/SKILL.md +9 -0
- package/skills/kill-switch-engineer/SKILL.md +9 -0
- package/skills/linddun-privacy-analyst/SKILL.md +9 -0
- package/skills/logic-race-fuzzer/SKILL.md +9 -0
- package/skills/mobile-api-network-attacker/SKILL.md +9 -0
- package/skills/mobile-binary-hardener/SKILL.md +9 -0
- package/skills/mobile-security-specialist/SKILL.md +9 -0
- package/skills/mobile-webview-auditor/SKILL.md +9 -0
- package/skills/model-extraction-attacker/SKILL.md +9 -0
- package/skills/multipart-abuse-tester/SKILL.md +9 -0
- package/skills/oauth-pkce-specialist/SKILL.md +9 -0
- package/skills/parser-exhaustion-tester/SKILL.md +9 -0
- package/skills/pentest-infra/SKILL.md +9 -0
- package/skills/pentest-social/SKILL.md +9 -0
- package/skills/pentest-team/SKILL.md +9 -0
- package/skills/pentest-web-api/SKILL.md +9 -0
- package/skills/privacy-flow-analyst/SKILL.md +9 -0
- package/skills/prompt-injection-specialist/SKILL.md +9 -0
- package/skills/quantum-migration-planner/SKILL.md +9 -0
- package/skills/rag-poisoning-specialist/SKILL.md +9 -0
- package/skills/registry-mirror-enforcer/SKILL.md +9 -0
- package/skills/rotation-validation-agent/SKILL.md +9 -0
- package/skills/samm-assessor/SKILL.md +9 -0
- package/skills/secrets-mask-bypass-tester/SKILL.md +9 -0
- package/skills/senior-security-engineer/SKILL.md +11 -0
- package/skills/serialization-memory-attacker/SKILL.md +9 -0
- package/skills/session-timeout-tester/SKILL.md +9 -0
- package/skills/slsa-level3-enforcer/SKILL.md +9 -0
- package/skills/slsa-provenance-enforcer/SKILL.md +9 -0
- package/skills/ssrf-detection-validator/SKILL.md +9 -0
- package/skills/step-up-auth-enforcer/SKILL.md +9 -0
- package/skills/stride-pasta-analyst/SKILL.md +9 -0
- package/skills/supply-chain-devsecops/SKILL.md +9 -0
- package/skills/threat-infrastructure-analyst/SKILL.md +9 -0
- package/skills/threat-modeler/SKILL.md +9 -0
- package/skills/tls-certificate-auditor/SKILL.md +9 -0
- package/skills/token-reuse-detector/SKILL.md +9 -0
- package/skills/trike-risk-modeler/SKILL.md +9 -0
- package/skills/unicode-homograph-tester/SKILL.md +9 -0
- package/skills/waf-rule-lifecycle-agent/SKILL.md +9 -0
- package/skills/webhook-security-tester/SKILL.md +9 -0
- package/skills/zero-trust-architect/SKILL.md +9 -0
|
@@ -62,7 +62,7 @@ const SECRET_PATTERNS = [
|
|
|
62
62
|
// Infrastructure tokens
|
|
63
63
|
{ name: "hashicorp_vault_token", regex: /\bhvs\.[A-Za-z0-9]{24,}\b/, description: "HashiCorp Vault service token" },
|
|
64
64
|
{ name: "npm_token", regex: /\bnpm_[A-Za-z0-9]{36}\b/, description: "npm access token" },
|
|
65
|
-
{ name: "npmrc_auth_token", regex: /_authToken\s*=\s*[A-Za-z0-9_
|
|
65
|
+
{ name: "npmrc_auth_token", regex: /_authToken\s*=\s*[A-Za-z0-9_\-.]{10,}/, description: "npm _authToken in .npmrc" },
|
|
66
66
|
{ name: "docker_hub_pat", regex: /\bdckr_pat_[A-Za-z0-9\-_]{27}\b/, description: "Docker Hub personal access token" },
|
|
67
67
|
{ name: "terraform_cloud_token", regex: /\b[A-Za-z0-9]{14}\.atlasv1\.[A-Za-z0-9]{60,}\b/, description: "Terraform Cloud token" },
|
|
68
68
|
{ name: "datadog_api_key", regex: /\bDD_API_KEY\s*[:=]\s*["'][a-fA-F0-9]{32}["']/, description: "Datadog API key" },
|
|
@@ -181,46 +181,58 @@ export async function checkSecrets(_) {
|
|
|
181
181
|
// ------------------------------------------------------------------
|
|
182
182
|
// Fix 2: Encoding evasion — base64 and hex secondary pass
|
|
183
183
|
// ------------------------------------------------------------------
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
184
|
+
// SECURITY (CWE-400): a single multi-MB contiguous base64/hex run makes V8's
|
|
185
|
+
// regex engine throw RangeError ("Maximum call stack size exceeded"). The
|
|
186
|
+
// readFileSafe size cap bounds file size, but contain any residual throw here
|
|
187
|
+
// so one crafted repo file cannot crash the gate (docs tier) or silently drop
|
|
188
|
+
// all secret findings (full tier swallows the rejection via Promise.allSettled).
|
|
189
|
+
try {
|
|
190
|
+
// Base64 candidates: length >= 20, valid base64 chars
|
|
191
|
+
const b64Regex = /[A-Za-z0-9+/]{20,}={0,2}/g;
|
|
192
|
+
let b64Match;
|
|
193
|
+
while ((b64Match = b64Regex.exec(text)) !== null) {
|
|
194
|
+
const candidate = b64Match[0];
|
|
195
|
+
try {
|
|
196
|
+
const decoded = Buffer.from(candidate, "base64").toString("utf8");
|
|
197
|
+
// Only proceed if decoded output looks like printable ASCII (avoid false positives on binary)
|
|
198
|
+
if (!/^[\x20-\x7E\t\r\n]{8,}$/.test(decoded))
|
|
199
|
+
continue;
|
|
200
|
+
const hit = matchSecretPatterns(decoded);
|
|
201
|
+
if (hit) {
|
|
202
|
+
const preview = previewLine(text, b64Match.index);
|
|
203
|
+
encodingHits.push(`${file}: base64-encoded ${hit.name} detected — encoded="${candidate.slice(0, 40)}…" decoded_match="[REDACTED]" context="${preview.slice(0, 80)}"`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// decode failed — skip
|
|
198
208
|
}
|
|
199
209
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
let hexMatch;
|
|
207
|
-
while ((hexMatch = hexRegex.exec(text)) !== null) {
|
|
208
|
-
const candidate = hexMatch[0];
|
|
209
|
-
if (candidate.length % 2 !== 0)
|
|
210
|
-
continue;
|
|
211
|
-
try {
|
|
212
|
-
const decoded = Buffer.from(candidate, "hex").toString("utf8");
|
|
213
|
-
if (!/^[\x20-\x7E\t\r\n]{8,}$/.test(decoded))
|
|
210
|
+
// Hex candidates: length >= 32, even number of hex chars
|
|
211
|
+
const hexRegex = /\b[0-9a-fA-F]{32,}\b/g;
|
|
212
|
+
let hexMatch;
|
|
213
|
+
while ((hexMatch = hexRegex.exec(text)) !== null) {
|
|
214
|
+
const candidate = hexMatch[0];
|
|
215
|
+
if (candidate.length % 2 !== 0)
|
|
214
216
|
continue;
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
try {
|
|
218
|
+
const decoded = Buffer.from(candidate, "hex").toString("utf8");
|
|
219
|
+
if (!/^[\x20-\x7E\t\r\n]{8,}$/.test(decoded))
|
|
220
|
+
continue;
|
|
221
|
+
const hit = matchSecretPatterns(decoded);
|
|
222
|
+
if (hit) {
|
|
223
|
+
const preview = previewLine(text, hexMatch.index);
|
|
224
|
+
encodingHits.push(`${file}: hex-encoded ${hit.name} detected — encoded="${candidate.slice(0, 40)}…" decoded_match="[REDACTED]" context="${preview.slice(0, 80)}"`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// decode failed — skip
|
|
219
229
|
}
|
|
220
230
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// CWE-400: regex engine RangeError or similar on a pathological file —
|
|
234
|
+
// skip this file's encoding pass rather than aborting the whole scan.
|
|
235
|
+
continue;
|
|
224
236
|
}
|
|
225
237
|
}
|
|
226
238
|
// ------------------------------------------------------------------
|
|
@@ -354,7 +366,7 @@ export async function checkSecrets(_) {
|
|
|
354
366
|
try {
|
|
355
367
|
await unlink(tmpReport);
|
|
356
368
|
}
|
|
357
|
-
catch { }
|
|
369
|
+
catch { /* ignore cleanup failure */ }
|
|
358
370
|
}
|
|
359
371
|
}
|
|
360
372
|
return findings;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import fg from "fast-glob";
|
|
3
|
+
import { readFileSafe } from "../../repo/fs.js";
|
|
4
|
+
import { applyEnsures } from "./hcl.js";
|
|
5
|
+
import { detectTerraform } from "./detect.js";
|
|
6
|
+
import { loadCloudRules } from "./types.js";
|
|
7
|
+
const TF_GLOBS = ["**/*.tf"];
|
|
8
|
+
const IGNORE = ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/.claude/**", "src/gate/**"];
|
|
9
|
+
const MAX_ITERATIONS = 500;
|
|
10
|
+
function violationKey(v) {
|
|
11
|
+
return `${v.rule.ruleId}@@${v.file}@@${v.block?.name ?? "?"}`;
|
|
12
|
+
}
|
|
13
|
+
function isAutoApplicable(rule) {
|
|
14
|
+
const s = rule.remediate.strategy;
|
|
15
|
+
if (s === "manual")
|
|
16
|
+
return false;
|
|
17
|
+
if (s === "companion-resource")
|
|
18
|
+
return Boolean(rule.remediate.companion);
|
|
19
|
+
return Boolean(rule.remediate.ensure); // set-attr | insert-block
|
|
20
|
+
}
|
|
21
|
+
/** Apply a single violation's remediation to the document, returning new text (or unchanged). */
|
|
22
|
+
function applyOne(text, v) {
|
|
23
|
+
const { remediate } = v.rule;
|
|
24
|
+
if (remediate.strategy === "companion-resource" && remediate.companion && v.block) {
|
|
25
|
+
const snippet = remediate.companion.replaceAll("${name}", v.block.name);
|
|
26
|
+
const sep = text.endsWith("\n") ? "\n" : "\n\n";
|
|
27
|
+
return text + sep + snippet.trimEnd() + "\n";
|
|
28
|
+
}
|
|
29
|
+
if (remediate.ensure && v.block) {
|
|
30
|
+
return applyEnsures(text, v.block, remediate.ensure);
|
|
31
|
+
}
|
|
32
|
+
return text;
|
|
33
|
+
}
|
|
34
|
+
/** Harden one Terraform document. Returns new text + per-violation outcomes. */
|
|
35
|
+
function hardenText(file, original, rules) {
|
|
36
|
+
let text = original;
|
|
37
|
+
const applied = [];
|
|
38
|
+
const manualMap = new Map();
|
|
39
|
+
const skip = new Set();
|
|
40
|
+
for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
|
|
41
|
+
const violations = detectTerraform(file, text, rules);
|
|
42
|
+
// Record manual / non-applicable violations once.
|
|
43
|
+
for (const v of violations) {
|
|
44
|
+
if (!isAutoApplicable(v.rule))
|
|
45
|
+
manualMap.set(violationKey(v), v);
|
|
46
|
+
}
|
|
47
|
+
const target = violations.find((v) => isAutoApplicable(v.rule) && !skip.has(violationKey(v)));
|
|
48
|
+
if (!target)
|
|
49
|
+
break;
|
|
50
|
+
const key = violationKey(target);
|
|
51
|
+
const candidate = applyOne(text, target);
|
|
52
|
+
if (candidate === text) {
|
|
53
|
+
skip.add(key);
|
|
54
|
+
manualMap.set(key, target);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// Verify the fix actually cleared this violation; otherwise revert + flag manual.
|
|
58
|
+
const after = detectTerraform(file, candidate, rules);
|
|
59
|
+
if (after.some((v) => violationKey(v) === key)) {
|
|
60
|
+
skip.add(key);
|
|
61
|
+
manualMap.set(key, target);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
text = candidate;
|
|
65
|
+
applied.push(target);
|
|
66
|
+
}
|
|
67
|
+
return { text, applied, manual: Array.from(manualMap.values()) };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Auto-harden every Terraform file in the working tree against the FSBP/CIS
|
|
71
|
+
* ruleset. Writes changes in place when `write` is true (default). Each applied
|
|
72
|
+
* edit is verified by re-running its own detector before being kept; edits that
|
|
73
|
+
* cannot be applied safely are reported as manual.
|
|
74
|
+
*/
|
|
75
|
+
export async function autoHardenTree(opts) {
|
|
76
|
+
const write = opts?.write !== false;
|
|
77
|
+
const rules = await loadCloudRules();
|
|
78
|
+
const report = { applied: [], manual: [], filesChanged: [] };
|
|
79
|
+
if (rules.length === 0)
|
|
80
|
+
return report;
|
|
81
|
+
const files = await fg(TF_GLOBS, { dot: true, followSymbolicLinks: false, ignore: IGNORE });
|
|
82
|
+
for (const file of files) {
|
|
83
|
+
let original = "";
|
|
84
|
+
try {
|
|
85
|
+
original = await readFileSafe(file);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const { text, applied, manual } = hardenText(file, original, rules);
|
|
91
|
+
for (const v of applied) {
|
|
92
|
+
report.applied.push({
|
|
93
|
+
ruleId: v.rule.ruleId,
|
|
94
|
+
file,
|
|
95
|
+
resource: `${v.rule.detect.resourceType}.${v.block?.name ?? "?"}`,
|
|
96
|
+
frameworks: v.rule.frameworks
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
for (const v of manual) {
|
|
100
|
+
report.manual.push({
|
|
101
|
+
ruleId: v.rule.ruleId,
|
|
102
|
+
file,
|
|
103
|
+
resource: `${v.rule.detect.resourceType}.${v.block?.name ?? "?"}`,
|
|
104
|
+
reason: v.reason,
|
|
105
|
+
snippet: v.rule.remediate.snippet
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (text !== original) {
|
|
109
|
+
report.filesChanged.push(file);
|
|
110
|
+
if (write)
|
|
111
|
+
await writeFile(file, text, "utf-8");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return report;
|
|
115
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Bicep resource extraction. Block-structured like HCL, so we reuse the
|
|
2
|
+
// brace-balanced matcher to capture each `resource <name> '<type>@<ver>' = { ... }`
|
|
3
|
+
// body. Detect-only — no auto-fix is attempted for Bicep.
|
|
4
|
+
import { matchBrace } from "./hcl.js";
|
|
5
|
+
// resource <symbolicName> '<type>@<apiVersion>' = [if (...)] { ... }
|
|
6
|
+
const RESOURCE_HEADER = /resource\s+([A-Za-z_][A-Za-z0-9_]*)\s+'([^']+)'\s*=\s*(?:if\s*\([^)]*\)\s*)?\{/g;
|
|
7
|
+
function lineOf(text, index) {
|
|
8
|
+
let line = 1;
|
|
9
|
+
for (let i = 0; i < index && i < text.length; i++) {
|
|
10
|
+
if (text[i] === "\n")
|
|
11
|
+
line++;
|
|
12
|
+
}
|
|
13
|
+
return line;
|
|
14
|
+
}
|
|
15
|
+
/** Parse all Bicep `resource` declarations, stripping the `@apiVersion` suffix from the type. */
|
|
16
|
+
export function parseBicepResources(text) {
|
|
17
|
+
const out = [];
|
|
18
|
+
RESOURCE_HEADER.lastIndex = 0;
|
|
19
|
+
let m;
|
|
20
|
+
while ((m = RESOURCE_HEADER.exec(text)) !== null) {
|
|
21
|
+
const open = text.indexOf("{", m.index);
|
|
22
|
+
if (open < 0)
|
|
23
|
+
continue;
|
|
24
|
+
const close = matchBrace(text, open);
|
|
25
|
+
if (close < 0)
|
|
26
|
+
continue;
|
|
27
|
+
out.push({
|
|
28
|
+
type: m[2].split("@")[0],
|
|
29
|
+
name: m[1],
|
|
30
|
+
body: text.slice(open + 1, close),
|
|
31
|
+
line: lineOf(text, m.index)
|
|
32
|
+
});
|
|
33
|
+
RESOURCE_HEADER.lastIndex = close + 1;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// CloudFormation / SAM template resource extraction (JSON and YAML).
|
|
2
|
+
//
|
|
3
|
+
// Detect-only: we locate each `Resources` entry and capture its text so a rule's
|
|
4
|
+
// forbid/require regex can be scoped to one resource. Heuristic — YAML is parsed
|
|
5
|
+
// by indentation, not a full YAML engine, so unusual layouts may be missed. No
|
|
6
|
+
// auto-fix is attempted for CloudFormation (JSON re-serialize drops comments;
|
|
7
|
+
// YAML anchors are unsafe to rewrite), so detection accuracy is sufficient.
|
|
8
|
+
const CFN_TYPE = /Type\s*:\s*['"]?(AWS::[A-Za-z0-9]+::[A-Za-z0-9]+)/;
|
|
9
|
+
/** Cheap pre-filter so we don't JSON.parse / scan every json/yaml in the repo. */
|
|
10
|
+
export function looksLikeCfn(text) {
|
|
11
|
+
return (text.includes("AWSTemplateFormatVersion") ||
|
|
12
|
+
text.includes("AWS::Serverless") ||
|
|
13
|
+
/["']?Type["']?\s*:\s*["']?AWS::/.test(text));
|
|
14
|
+
}
|
|
15
|
+
function lineOf(text, index) {
|
|
16
|
+
let line = 1;
|
|
17
|
+
for (let i = 0; i < index && i < text.length; i++) {
|
|
18
|
+
if (text[i] === "\n")
|
|
19
|
+
line++;
|
|
20
|
+
}
|
|
21
|
+
return line;
|
|
22
|
+
}
|
|
23
|
+
function escapeRegex(s) {
|
|
24
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
25
|
+
}
|
|
26
|
+
function asRecord(v) {
|
|
27
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
28
|
+
}
|
|
29
|
+
function parseJsonCfn(text) {
|
|
30
|
+
let doc;
|
|
31
|
+
try {
|
|
32
|
+
doc = JSON.parse(text);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const root = asRecord(doc);
|
|
38
|
+
const resources = root && asRecord(root["Resources"]);
|
|
39
|
+
if (!resources)
|
|
40
|
+
return [];
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const [name, resVal] of Object.entries(resources)) {
|
|
43
|
+
const res = asRecord(resVal);
|
|
44
|
+
const type = res?.["Type"];
|
|
45
|
+
if (typeof type !== "string" || !type.startsWith("AWS::"))
|
|
46
|
+
continue;
|
|
47
|
+
const re = new RegExp(`"${escapeRegex(name)}"\\s*:`);
|
|
48
|
+
const m = re.exec(text);
|
|
49
|
+
out.push({ type, name, body: JSON.stringify(res), line: m ? lineOf(text, m.index) : 1 });
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
function indentOf(line) {
|
|
54
|
+
return line.length - line.trimStart().length;
|
|
55
|
+
}
|
|
56
|
+
function findResourcesIndent(lines) {
|
|
57
|
+
for (let i = 0; i < lines.length; i++) {
|
|
58
|
+
const m = /^(\s*)Resources\s*:\s*$/.exec(lines[i]);
|
|
59
|
+
if (m)
|
|
60
|
+
return { start: i, indent: m[1].length };
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
function parseYamlCfn(text) {
|
|
65
|
+
const lines = text.split("\n");
|
|
66
|
+
const res = findResourcesIndent(lines);
|
|
67
|
+
if (!res)
|
|
68
|
+
return [];
|
|
69
|
+
// First logical-id sits at the indent level directly under "Resources:".
|
|
70
|
+
let childIndent = -1;
|
|
71
|
+
for (let i = res.start + 1; i < lines.length; i++) {
|
|
72
|
+
if (!lines[i].trim() || /^\s*#/.test(lines[i]))
|
|
73
|
+
continue;
|
|
74
|
+
const ind = indentOf(lines[i]);
|
|
75
|
+
if (ind <= res.indent)
|
|
76
|
+
return [];
|
|
77
|
+
childIndent = ind;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
if (childIndent < 0)
|
|
81
|
+
return [];
|
|
82
|
+
const out = [];
|
|
83
|
+
let name = "";
|
|
84
|
+
let start = -1;
|
|
85
|
+
let body = [];
|
|
86
|
+
const flush = () => {
|
|
87
|
+
if (!name)
|
|
88
|
+
return;
|
|
89
|
+
const text2 = body.join("\n");
|
|
90
|
+
const tm = CFN_TYPE.exec(text2);
|
|
91
|
+
if (tm)
|
|
92
|
+
out.push({ type: tm[1], name, body: text2, line: start + 1 });
|
|
93
|
+
name = "";
|
|
94
|
+
body = [];
|
|
95
|
+
};
|
|
96
|
+
for (let i = res.start + 1; i < lines.length; i++) {
|
|
97
|
+
const l = lines[i];
|
|
98
|
+
if (!l.trim()) {
|
|
99
|
+
if (name)
|
|
100
|
+
body.push(l);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const ind = indentOf(l);
|
|
104
|
+
if (ind <= res.indent) {
|
|
105
|
+
flush();
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
if (ind === childIndent) {
|
|
109
|
+
const hm = /^\s*([A-Za-z0-9_]+)\s*:/.exec(l);
|
|
110
|
+
flush();
|
|
111
|
+
name = hm ? hm[1] : "";
|
|
112
|
+
start = i;
|
|
113
|
+
body = [l];
|
|
114
|
+
}
|
|
115
|
+
else if (name) {
|
|
116
|
+
body.push(l);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
flush();
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
/** Parse all CloudFormation resources from a JSON or YAML template. */
|
|
123
|
+
export function parseCfnResources(text) {
|
|
124
|
+
return text.trimStart().startsWith("{") ? parseJsonCfn(text) : parseYamlCfn(text);
|
|
125
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { parseResourceBlocks } from "./hcl.js";
|
|
2
|
+
import { looksLikeCfn, parseCfnResources } from "./cfn.js";
|
|
3
|
+
import { parseBicepResources } from "./bicep.js";
|
|
4
|
+
function lineOf(text, index) {
|
|
5
|
+
let line = 1;
|
|
6
|
+
for (let i = 0; i < index && i < text.length; i++) {
|
|
7
|
+
if (text[i] === "\n")
|
|
8
|
+
line++;
|
|
9
|
+
}
|
|
10
|
+
return line;
|
|
11
|
+
}
|
|
12
|
+
function blockBody(text, block) {
|
|
13
|
+
return text.slice(block.bodyStart, block.bodyEnd);
|
|
14
|
+
}
|
|
15
|
+
function compile(pattern) {
|
|
16
|
+
return new RegExp(pattern, "i");
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Does the file contain a resource of `companionType` that references
|
|
20
|
+
* `origType.name` (the offending resource)? Used for cross-resource rules such
|
|
21
|
+
* as an S3 bucket needing a matching aws_s3_bucket_public_access_block.
|
|
22
|
+
*/
|
|
23
|
+
function companionExists(blocks, fullText, companionType, origType, name) {
|
|
24
|
+
const ref = `${origType}.${name}`;
|
|
25
|
+
return blocks.some((b) => b.type === companionType && blockBody(fullText, b).includes(ref));
|
|
26
|
+
}
|
|
27
|
+
/** Evaluate every terraform-target rule against one parsed HCL document. */
|
|
28
|
+
export function detectTerraform(file, text, rules) {
|
|
29
|
+
const blocks = parseResourceBlocks(text);
|
|
30
|
+
if (blocks.length === 0)
|
|
31
|
+
return [];
|
|
32
|
+
const violations = [];
|
|
33
|
+
for (const rule of rules) {
|
|
34
|
+
if (rule.detect.target !== "terraform")
|
|
35
|
+
continue;
|
|
36
|
+
const { resourceType, forbid, require, requireCompanionType } = rule.detect;
|
|
37
|
+
const forbidRe = forbid ? compile(forbid) : null;
|
|
38
|
+
const requireRe = require ? compile(require) : null;
|
|
39
|
+
for (const block of blocks) {
|
|
40
|
+
if (block.type !== resourceType)
|
|
41
|
+
continue;
|
|
42
|
+
const body = blockBody(text, block);
|
|
43
|
+
const line = lineOf(text, block.start);
|
|
44
|
+
if (forbidRe && forbidRe.test(body)) {
|
|
45
|
+
violations.push({ rule, file, line, block, reason: "insecure value present" });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (requireRe && !requireRe.test(body)) {
|
|
49
|
+
violations.push({ rule, file, line, block, reason: "secure setting missing" });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (requireCompanionType &&
|
|
53
|
+
!companionExists(blocks, text, requireCompanionType, resourceType, block.name)) {
|
|
54
|
+
violations.push({
|
|
55
|
+
rule,
|
|
56
|
+
file,
|
|
57
|
+
line,
|
|
58
|
+
block,
|
|
59
|
+
reason: `missing companion ${requireCompanionType}`
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return violations;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Evaluate forbid/require rules for one target against a list of already-parsed
|
|
68
|
+
* resources (CloudFormation or Bicep). Body-scoped regex, detect-only.
|
|
69
|
+
*/
|
|
70
|
+
function detectResources(file, rules, target, resources) {
|
|
71
|
+
if (resources.length === 0)
|
|
72
|
+
return [];
|
|
73
|
+
const violations = [];
|
|
74
|
+
for (const rule of rules) {
|
|
75
|
+
if (rule.detect.target !== target)
|
|
76
|
+
continue;
|
|
77
|
+
const { resourceType, forbid, require } = rule.detect;
|
|
78
|
+
const forbidRe = forbid ? compile(forbid) : null;
|
|
79
|
+
const requireRe = require ? compile(require) : null;
|
|
80
|
+
for (const res of resources) {
|
|
81
|
+
if (res.type !== resourceType)
|
|
82
|
+
continue;
|
|
83
|
+
if (forbidRe && forbidRe.test(res.body)) {
|
|
84
|
+
violations.push({ rule, file, line: res.line, reason: "insecure value present" });
|
|
85
|
+
}
|
|
86
|
+
else if (requireRe && !requireRe.test(res.body)) {
|
|
87
|
+
violations.push({ rule, file, line: res.line, reason: "secure setting missing" });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return violations;
|
|
92
|
+
}
|
|
93
|
+
/** Evaluate cloudformation-target rules against a JSON or YAML template. */
|
|
94
|
+
export function detectCloudFormation(file, text, rules) {
|
|
95
|
+
if (!looksLikeCfn(text))
|
|
96
|
+
return [];
|
|
97
|
+
const resources = parseCfnResources(text);
|
|
98
|
+
return detectResources(file, rules, "cloudformation", resources);
|
|
99
|
+
}
|
|
100
|
+
/** Evaluate bicep-target rules against a Bicep document. */
|
|
101
|
+
export function detectBicep(file, text, rules) {
|
|
102
|
+
const resources = parseBicepResources(text);
|
|
103
|
+
return detectResources(file, rules, "bicep", resources);
|
|
104
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Lightweight, dependency-free HCL (Terraform) block utilities.
|
|
2
|
+
//
|
|
3
|
+
// These are intentionally heuristic — brace-balanced scanning, not a full HCL
|
|
4
|
+
// parser. They are robust enough to locate `resource "type" "name" { ... }`
|
|
5
|
+
// blocks and apply scoped attribute edits inside a single block, which is all
|
|
6
|
+
// the detect-and-remediate engine needs. Edge cases (heredocs, unusual
|
|
7
|
+
// formatting) may be missed; such rules degrade to "manual" remediation.
|
|
8
|
+
const RESOURCE_HEADER = /resource\s+"([^"]+)"\s+"([^"]+)"\s*\{/g;
|
|
9
|
+
/**
|
|
10
|
+
* Find the index of the "}" matching the "{" at openIdx. Skips braces that
|
|
11
|
+
* appear inside double-quoted strings and line comments (# and //).
|
|
12
|
+
* Returns -1 if unbalanced.
|
|
13
|
+
*/
|
|
14
|
+
export function matchBrace(text, openIdx) {
|
|
15
|
+
let depth = 0;
|
|
16
|
+
let inString = false;
|
|
17
|
+
let inLineComment = false;
|
|
18
|
+
for (let i = openIdx; i < text.length; i++) {
|
|
19
|
+
const ch = text[i];
|
|
20
|
+
if (inLineComment) {
|
|
21
|
+
if (ch === "\n")
|
|
22
|
+
inLineComment = false;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (inString) {
|
|
26
|
+
if (ch === "\\") {
|
|
27
|
+
i++; // skip escaped char
|
|
28
|
+
}
|
|
29
|
+
else if (ch === '"') {
|
|
30
|
+
inString = false;
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (ch === '"') {
|
|
35
|
+
inString = true;
|
|
36
|
+
}
|
|
37
|
+
else if (ch === "#") {
|
|
38
|
+
inLineComment = true;
|
|
39
|
+
}
|
|
40
|
+
else if (ch === "/" && text[i + 1] === "/") {
|
|
41
|
+
inLineComment = true;
|
|
42
|
+
i++;
|
|
43
|
+
}
|
|
44
|
+
else if (ch === "{") {
|
|
45
|
+
depth++;
|
|
46
|
+
}
|
|
47
|
+
else if (ch === "}") {
|
|
48
|
+
depth--;
|
|
49
|
+
if (depth === 0)
|
|
50
|
+
return i;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return -1;
|
|
54
|
+
}
|
|
55
|
+
/** Parse all top-level `resource "type" "name"` blocks in a Terraform document. */
|
|
56
|
+
export function parseResourceBlocks(text) {
|
|
57
|
+
const blocks = [];
|
|
58
|
+
RESOURCE_HEADER.lastIndex = 0;
|
|
59
|
+
let m;
|
|
60
|
+
while ((m = RESOURCE_HEADER.exec(text)) !== null) {
|
|
61
|
+
const openBrace = text.indexOf("{", m.index);
|
|
62
|
+
if (openBrace < 0)
|
|
63
|
+
continue;
|
|
64
|
+
const close = matchBrace(text, openBrace);
|
|
65
|
+
if (close < 0)
|
|
66
|
+
continue;
|
|
67
|
+
blocks.push({
|
|
68
|
+
type: m[1],
|
|
69
|
+
name: m[2],
|
|
70
|
+
start: m.index,
|
|
71
|
+
end: close + 1,
|
|
72
|
+
bodyStart: openBrace + 1,
|
|
73
|
+
bodyEnd: close
|
|
74
|
+
});
|
|
75
|
+
RESOURCE_HEADER.lastIndex = close + 1;
|
|
76
|
+
}
|
|
77
|
+
return blocks;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Ensure `attr = value` exists inside an HCL body string for a single-segment
|
|
81
|
+
* attribute. Replaces an existing assignment (even if the value differs) or
|
|
82
|
+
* inserts a new one at the top of the body. Returns the new body string.
|
|
83
|
+
*/
|
|
84
|
+
function ensureLeafAttr(body, attr, value, indent) {
|
|
85
|
+
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
86
|
+
const assignRe = new RegExp(`^([ \\t]*)${escaped}(\\s*)=.*$`, "m");
|
|
87
|
+
if (assignRe.test(body)) {
|
|
88
|
+
return body.replace(assignRe, `$1${attr} = ${value}`);
|
|
89
|
+
}
|
|
90
|
+
const leading = body.startsWith("\n") ? "\n" : "";
|
|
91
|
+
const rest = body.startsWith("\n") ? body.slice(1) : body;
|
|
92
|
+
return `${leading}${indent}${attr} = ${value}\n${rest}`;
|
|
93
|
+
}
|
|
94
|
+
/** Build a fresh nested block chain `a { b { leaf = value } }` for a missing path. */
|
|
95
|
+
function buildNestedChain(segs, value, indent) {
|
|
96
|
+
const [head, ...rest] = segs;
|
|
97
|
+
if (rest.length === 0) {
|
|
98
|
+
return `${indent}${head} = ${value}\n`;
|
|
99
|
+
}
|
|
100
|
+
const inner = buildNestedChain(rest, value, indent + " ");
|
|
101
|
+
return `${indent}${head} {\n${inner}${indent}}\n`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Ensure a nested-path attribute `a.b.c = value` inside an HCL body, creating
|
|
105
|
+
* any missing intermediate `block { ... }` levels. Handles arbitrary depth.
|
|
106
|
+
*/
|
|
107
|
+
function ensurePath(body, segs, value, indent) {
|
|
108
|
+
if (segs.length === 1) {
|
|
109
|
+
return ensureLeafAttr(body, segs[0], value, indent);
|
|
110
|
+
}
|
|
111
|
+
const [parent, ...rest] = segs;
|
|
112
|
+
const escaped = parent.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
113
|
+
const headerRe = new RegExp(`(^|\\n)([ \\t]*)${escaped}\\s*\\{`);
|
|
114
|
+
const match = headerRe.exec(body);
|
|
115
|
+
if (match) {
|
|
116
|
+
const openBrace = body.indexOf("{", match.index + match[1].length);
|
|
117
|
+
const close = matchBrace(body, openBrace);
|
|
118
|
+
if (openBrace >= 0 && close >= 0) {
|
|
119
|
+
const innerBody = body.slice(openBrace + 1, close);
|
|
120
|
+
const newInner = ensurePath(innerBody, rest, value, indent + " ");
|
|
121
|
+
return body.slice(0, openBrace + 1) + newInner + body.slice(close);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const block = buildNestedChain(segs, value, indent);
|
|
125
|
+
const leading = body.startsWith("\n") ? "\n" : "";
|
|
126
|
+
const restBody = body.startsWith("\n") ? body.slice(1) : body;
|
|
127
|
+
return `${leading}${block}${restBody}`;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Apply a set of dotted-path attribute assignments to one resource block in
|
|
131
|
+
* fullText, returning the rewritten document. Supports depth 1 (attr) and 2
|
|
132
|
+
* (parent.child). Unknown depths are skipped.
|
|
133
|
+
*/
|
|
134
|
+
export function applyEnsures(fullText, block, ensure) {
|
|
135
|
+
let body = fullText.slice(block.bodyStart, block.bodyEnd);
|
|
136
|
+
for (const [path, value] of Object.entries(ensure)) {
|
|
137
|
+
body = ensurePath(body, path.split("."), value, " ");
|
|
138
|
+
}
|
|
139
|
+
return fullText.slice(0, block.bodyStart) + body + fullText.slice(block.bodyEnd);
|
|
140
|
+
}
|