security-mcp 1.3.1 → 1.3.3

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.
Files changed (131) hide show
  1. package/README.md +356 -885
  2. package/defaults/cloud-controls/aws.json +10712 -0
  3. package/defaults/cloud-controls/azure.json +7201 -0
  4. package/defaults/cloud-controls/gcp.json +4061 -0
  5. package/defaults/control-catalog.json +24 -0
  6. package/dist/ci/pr-gate.js +22 -5
  7. package/dist/cli/index.js +73 -2
  8. package/dist/cli/install.js +4 -55
  9. package/dist/cli/onboarding.js +18 -10
  10. package/dist/gate/checks/agentic-instructions.js +515 -0
  11. package/dist/gate/checks/ai-governance.js +132 -0
  12. package/dist/gate/checks/ai.js +1 -1
  13. package/dist/gate/checks/cloud-controls.js +69 -0
  14. package/dist/gate/checks/crypto.js +1 -1
  15. package/dist/gate/checks/data-platform.js +954 -0
  16. package/dist/gate/checks/dependencies.js +14 -3
  17. package/dist/gate/checks/docker-deep.js +1236 -0
  18. package/dist/gate/checks/gitops.js +724 -0
  19. package/dist/gate/checks/iac.js +1230 -0
  20. package/dist/gate/checks/k8s.js +841 -1
  21. package/dist/gate/checks/secrets.js +49 -37
  22. package/dist/gate/cloud-controls/apply.js +115 -0
  23. package/dist/gate/cloud-controls/bicep.js +36 -0
  24. package/dist/gate/cloud-controls/cfn.js +125 -0
  25. package/dist/gate/cloud-controls/detect.js +104 -0
  26. package/dist/gate/cloud-controls/hcl.js +140 -0
  27. package/dist/gate/cloud-controls/types.js +87 -0
  28. package/dist/gate/exceptions.js +78 -7
  29. package/dist/gate/findings.js +15 -2
  30. package/dist/gate/policy.js +40 -3
  31. package/dist/gate/threat-intel.js +6 -0
  32. package/dist/mcp/audit-chain.js +9 -0
  33. package/dist/mcp/model-router.js +3 -3
  34. package/dist/mcp/orchestration.js +194 -41
  35. package/dist/mcp/server.js +124 -17
  36. package/dist/mcp/tool-audit.js +193 -0
  37. package/dist/repo/fs.js +14 -1
  38. package/dist/review/store.js +4 -2
  39. package/dist/tests/run.js +124 -1
  40. package/package.json +3 -3
  41. package/skills/advanced-dos-tester/SKILL.md +9 -0
  42. package/skills/agentic-instruction-auditor/SKILL.md +111 -0
  43. package/skills/agentic-loop-exploiter/SKILL.md +9 -0
  44. package/skills/ai-llm-redteam/SKILL.md +9 -0
  45. package/skills/ai-model-supply-chain-agent/SKILL.md +9 -0
  46. package/skills/algorithm-implementation-reviewer/SKILL.md +9 -0
  47. package/skills/android-penetration-tester/SKILL.md +9 -0
  48. package/skills/anti-replay-tester/SKILL.md +9 -0
  49. package/skills/appsec-code-auditor/SKILL.md +9 -0
  50. package/skills/artifact-integrity-analyst/SKILL.md +9 -0
  51. package/skills/attack-navigator/SKILL.md +9 -0
  52. package/skills/auth-session-hacker/SKILL.md +9 -0
  53. package/skills/aws-penetration-tester/SKILL.md +54 -0
  54. package/skills/azure-penetration-tester/SKILL.md +52 -0
  55. package/skills/binary-auth-validator/SKILL.md +9 -0
  56. package/skills/bot-detection-specialist/SKILL.md +9 -0
  57. package/skills/business-logic-attacker/SKILL.md +9 -0
  58. package/skills/capec-code-mapper/SKILL.md +9 -0
  59. package/skills/cert-pin-rotation-specialist/SKILL.md +9 -0
  60. package/skills/cicd-pipeline-hijacker/SKILL.md +9 -0
  61. package/skills/ciso-orchestrator/SKILL.md +11 -0
  62. package/skills/cloud-infra-specialist/SKILL.md +9 -0
  63. package/skills/compliance-gap-analyst/SKILL.md +9 -0
  64. package/skills/compliance-grc/SKILL.md +9 -0
  65. package/skills/compliance-lifecycle-tracker/SKILL.md +9 -0
  66. package/skills/container-hardening-auditor/SKILL.md +125 -0
  67. package/skills/credential-stuffing-specialist/SKILL.md +9 -0
  68. package/skills/crypto-pki-specialist/SKILL.md +9 -0
  69. package/skills/csa-ccm-mapper/SKILL.md +9 -0
  70. package/skills/csf2-governance-mapper/SKILL.md +9 -0
  71. package/skills/data-platform-auditor/SKILL.md +125 -0
  72. package/skills/deep-link-fuzzer/SKILL.md +9 -0
  73. package/skills/dependency-confusion-attacker/SKILL.md +9 -0
  74. package/skills/device-integrity-aggregator/SKILL.md +9 -0
  75. package/skills/dos-resilience-tester/SKILL.md +9 -0
  76. package/skills/dread-scorer/SKILL.md +9 -0
  77. package/skills/egress-policy-enforcer/SKILL.md +9 -0
  78. package/skills/evidence-collector/SKILL.md +9 -0
  79. package/skills/file-upload-attacker/SKILL.md +9 -0
  80. package/skills/gcp-penetration-tester/SKILL.md +51 -0
  81. package/skills/git-history-secret-scanner/SKILL.md +9 -0
  82. package/skills/gitops-delivery-auditor/SKILL.md +120 -0
  83. package/skills/iac-security-auditor/SKILL.md +125 -0
  84. package/skills/iam-privesc-graph-builder/SKILL.md +9 -0
  85. package/skills/incident-responder/SKILL.md +9 -0
  86. package/skills/injection-specialist/SKILL.md +9 -0
  87. package/skills/ios-security-auditor/SKILL.md +9 -0
  88. package/skills/json-ambiguity-tester/SKILL.md +0 -0
  89. package/skills/k8s-container-escaper/SKILL.md +22 -0
  90. package/skills/key-management-lifecycle-analyst/SKILL.md +9 -0
  91. package/skills/kill-switch-engineer/SKILL.md +9 -0
  92. package/skills/linddun-privacy-analyst/SKILL.md +9 -0
  93. package/skills/logic-race-fuzzer/SKILL.md +9 -0
  94. package/skills/mobile-api-network-attacker/SKILL.md +9 -0
  95. package/skills/mobile-binary-hardener/SKILL.md +9 -0
  96. package/skills/mobile-security-specialist/SKILL.md +9 -0
  97. package/skills/mobile-webview-auditor/SKILL.md +9 -0
  98. package/skills/model-extraction-attacker/SKILL.md +9 -0
  99. package/skills/multipart-abuse-tester/SKILL.md +9 -0
  100. package/skills/oauth-pkce-specialist/SKILL.md +9 -0
  101. package/skills/parser-exhaustion-tester/SKILL.md +9 -0
  102. package/skills/pentest-infra/SKILL.md +9 -0
  103. package/skills/pentest-social/SKILL.md +9 -0
  104. package/skills/pentest-team/SKILL.md +9 -0
  105. package/skills/pentest-web-api/SKILL.md +9 -0
  106. package/skills/privacy-flow-analyst/SKILL.md +9 -0
  107. package/skills/prompt-injection-specialist/SKILL.md +9 -0
  108. package/skills/quantum-migration-planner/SKILL.md +9 -0
  109. package/skills/rag-poisoning-specialist/SKILL.md +9 -0
  110. package/skills/registry-mirror-enforcer/SKILL.md +9 -0
  111. package/skills/rotation-validation-agent/SKILL.md +9 -0
  112. package/skills/samm-assessor/SKILL.md +9 -0
  113. package/skills/secrets-mask-bypass-tester/SKILL.md +9 -0
  114. package/skills/senior-security-engineer/SKILL.md +11 -0
  115. package/skills/serialization-memory-attacker/SKILL.md +9 -0
  116. package/skills/session-timeout-tester/SKILL.md +9 -0
  117. package/skills/slsa-level3-enforcer/SKILL.md +9 -0
  118. package/skills/slsa-provenance-enforcer/SKILL.md +9 -0
  119. package/skills/ssrf-detection-validator/SKILL.md +9 -0
  120. package/skills/step-up-auth-enforcer/SKILL.md +9 -0
  121. package/skills/stride-pasta-analyst/SKILL.md +9 -0
  122. package/skills/supply-chain-devsecops/SKILL.md +9 -0
  123. package/skills/threat-infrastructure-analyst/SKILL.md +9 -0
  124. package/skills/threat-modeler/SKILL.md +9 -0
  125. package/skills/tls-certificate-auditor/SKILL.md +9 -0
  126. package/skills/token-reuse-detector/SKILL.md +9 -0
  127. package/skills/trike-risk-modeler/SKILL.md +9 -0
  128. package/skills/unicode-homograph-tester/SKILL.md +9 -0
  129. package/skills/waf-rule-lifecycle-agent/SKILL.md +9 -0
  130. package/skills/webhook-security-tester/SKILL.md +9 -0
  131. 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_\-\.]{10,}/, description: "npm _authToken in .npmrc" },
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
- // Base64 candidates: length >= 20, valid base64 chars
185
- const b64Regex = /[A-Za-z0-9+/]{20,}={0,2}/g;
186
- let b64Match;
187
- while ((b64Match = b64Regex.exec(text)) !== null) {
188
- const candidate = b64Match[0];
189
- try {
190
- const decoded = Buffer.from(candidate, "base64").toString("utf8");
191
- // Only proceed if decoded output looks like printable ASCII (avoid false positives on binary)
192
- if (!/^[\x20-\x7E\t\r\n]{8,}$/.test(decoded))
193
- continue;
194
- const hit = matchSecretPatterns(decoded);
195
- if (hit) {
196
- const preview = previewLine(text, b64Match.index);
197
- encodingHits.push(`${file}: base64-encoded ${hit.name} detected encoded="${candidate.slice(0, 40)}…" decoded_match="[REDACTED]" context="${preview.slice(0, 80)}"`);
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
- catch {
201
- // decode failed — skip
202
- }
203
- }
204
- // Hex candidates: length >= 32, even number of hex chars
205
- const hexRegex = /\b[0-9a-fA-F]{32,}\b/g;
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
- const hit = matchSecretPatterns(decoded);
216
- if (hit) {
217
- const preview = previewLine(text, hexMatch.index);
218
- encodingHits.push(`${file}: hex-encoded ${hit.name} detected — encoded="${candidate.slice(0, 40)}…" decoded_match="[REDACTED]" context="${preview.slice(0, 80)}"`);
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
- catch {
222
- // decode failed — skip
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
+ }