@skaleagents/swarm 0.6.9 → 0.6.11
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 +3 -1
- package/dist/iac/parse.d.ts +6 -1
- package/dist/iac/parse.js +425 -4
- package/dist/iac/rules.js +126 -0
- package/dist/server.js +11 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,7 +62,9 @@ review-brief builder.
|
|
|
62
62
|
## Infrastructure scanning
|
|
63
63
|
|
|
64
64
|
`scan_iac` parses Terraform HCL/JSON, CloudFormation YAML/JSON, and Kubernetes
|
|
65
|
-
manifests, including multi-document YAML and Kubernetes Lists. It
|
|
65
|
+
manifests, including multi-document YAML and Kubernetes Lists. It also reads
|
|
66
|
+
Dockerfiles, GitHub Actions workflows, Helm templates, Ansible playbooks,
|
|
67
|
+
Bicep files, and ARM templates. It returns a
|
|
66
68
|
resource inventory and findings with stable rule IDs, severity, property paths,
|
|
67
69
|
line locations, and remediation. Findings never include matched secret values.
|
|
68
70
|
|
package/dist/iac/parse.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type IacFormat = "terraform" | "cloudformation" | "kubernetes" | "pulumi" | "compose" | "dockerfile" | "github";
|
|
1
|
+
export type IacFormat = "terraform" | "cloudformation" | "kubernetes" | "pulumi" | "compose" | "dockerfile" | "github" | "helm" | "ansible" | "bicep" | "arm";
|
|
2
2
|
export type Path = (string | number)[];
|
|
3
3
|
export type Location = {
|
|
4
4
|
line: number;
|
|
@@ -25,6 +25,11 @@ export declare function looksLikeIac(content: string): boolean;
|
|
|
25
25
|
export declare function looksLikePulumiProgram(content: string): boolean;
|
|
26
26
|
export declare function looksLikeCompose(content: string): boolean;
|
|
27
27
|
export declare function looksLikeDockerfile(content: string): boolean;
|
|
28
|
+
export declare function looksLikeAnsible(content: string): boolean;
|
|
29
|
+
export declare function looksLikeHelm(content: string): boolean;
|
|
28
30
|
export declare function looksLikeGithubWorkflow(content: string): boolean;
|
|
31
|
+
export declare function looksLikeTerraformHcl(content: string): boolean;
|
|
32
|
+
export declare function looksLikeBicep(content: string): boolean;
|
|
33
|
+
export declare function looksLikeArm(content: string): boolean;
|
|
29
34
|
export declare function looksLikePulumiYaml(content: string): boolean;
|
|
30
35
|
export declare function parseIac(content: string, requested?: IacFormat | "auto"): ParsedIac;
|
package/dist/iac/parse.js
CHANGED
|
@@ -42,7 +42,11 @@ export function looksLikeIac(content) {
|
|
|
42
42
|
looksLikePulumiProgram(content) ||
|
|
43
43
|
looksLikeCompose(content) ||
|
|
44
44
|
looksLikeDockerfile(content) ||
|
|
45
|
-
looksLikeGithubWorkflow(content)
|
|
45
|
+
looksLikeGithubWorkflow(content) ||
|
|
46
|
+
looksLikeHelm(content) ||
|
|
47
|
+
looksLikeAnsible(content) ||
|
|
48
|
+
looksLikeBicep(content) ||
|
|
49
|
+
looksLikeArm(content));
|
|
46
50
|
}
|
|
47
51
|
export function looksLikePulumiProgram(content) {
|
|
48
52
|
return /(?:^|\n)\s*(?:import\s+\*\s+as\s+pulumi\b|import\s+pulumi\b|from\s+["']@pulumi\/)/.test(content);
|
|
@@ -56,6 +60,26 @@ export function looksLikeDockerfile(content) {
|
|
|
56
60
|
function topLevelKey(content, name) {
|
|
57
61
|
return new RegExp(`^(?:"${name}"|'${name}'|${name})\\s*:`, "m").test(content);
|
|
58
62
|
}
|
|
63
|
+
function playbookShape(content) {
|
|
64
|
+
if (topLevelKey(content, "hosts") && topLevelKey(content, "tasks"))
|
|
65
|
+
return true;
|
|
66
|
+
return /^\s*-\s+hosts\s*:/m.test(content) && /^\s*tasks\s*:/m.test(content);
|
|
67
|
+
}
|
|
68
|
+
export function looksLikeAnsible(content) {
|
|
69
|
+
if (looksLikeGithubWorkflow(content))
|
|
70
|
+
return false;
|
|
71
|
+
if (topLevelKey(content, "apiVersion") && topLevelKey(content, "kind"))
|
|
72
|
+
return false;
|
|
73
|
+
return playbookShape(content);
|
|
74
|
+
}
|
|
75
|
+
export function looksLikeHelm(content) {
|
|
76
|
+
if (looksLikeGithubWorkflow(content) || looksLikeAnsible(content))
|
|
77
|
+
return false;
|
|
78
|
+
const visible = maskNonCode(content);
|
|
79
|
+
if (!visible.includes("{{"))
|
|
80
|
+
return false;
|
|
81
|
+
return /^\s*(?:apiVersion|kind)\s*:/m.test(visible);
|
|
82
|
+
}
|
|
59
83
|
export function looksLikeGithubWorkflow(content) {
|
|
60
84
|
if (topLevelKey(content, "apiVersion") || topLevelKey(content, "kind"))
|
|
61
85
|
return false;
|
|
@@ -67,6 +91,44 @@ export function looksLikeGithubWorkflow(content) {
|
|
|
67
91
|
return false;
|
|
68
92
|
return topOn && topLevelKey(content, "jobs");
|
|
69
93
|
}
|
|
94
|
+
export function looksLikeTerraformHcl(content) {
|
|
95
|
+
return /^\s*(?:(?:resource|module|variable|provider|data|output)\s+"|(?:terraform|locals)\s*\{)/m.test(content);
|
|
96
|
+
}
|
|
97
|
+
export function looksLikeBicep(content) {
|
|
98
|
+
const visible = maskIaCComments(content);
|
|
99
|
+
if (looksLikeTerraformHcl(visible))
|
|
100
|
+
return false;
|
|
101
|
+
return /^\s*resource\s+[A-Za-z_][A-Za-z0-9_]*\s+['"]Microsoft\./m.test(visible);
|
|
102
|
+
}
|
|
103
|
+
export function looksLikeArm(content) {
|
|
104
|
+
const visible = maskIaCComments(content).trim();
|
|
105
|
+
if (!visible.startsWith("{"))
|
|
106
|
+
return false;
|
|
107
|
+
let value;
|
|
108
|
+
try {
|
|
109
|
+
value = JSON.parse(visible);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const root = object(value);
|
|
115
|
+
if (typeof root.AWSTemplateFormatVersion === "string")
|
|
116
|
+
return false;
|
|
117
|
+
if (Object.values(object(root.Resources)).some((item) => String(object(item).Type ?? "").startsWith("AWS::")))
|
|
118
|
+
return false;
|
|
119
|
+
if ("resource" in root && !Array.isArray(root.resources))
|
|
120
|
+
return false;
|
|
121
|
+
if ("format_version" in root ||
|
|
122
|
+
"planned_values" in root ||
|
|
123
|
+
"resource_changes" in root)
|
|
124
|
+
return false;
|
|
125
|
+
if (typeof root.$schema === "string" &&
|
|
126
|
+
root.$schema.includes("deploymentTemplate"))
|
|
127
|
+
return true;
|
|
128
|
+
if (typeof root.contentVersion !== "string" || !Array.isArray(root.resources))
|
|
129
|
+
return false;
|
|
130
|
+
return root.resources.some((item) => String(object(item).type ?? "").startsWith("Microsoft."));
|
|
131
|
+
}
|
|
70
132
|
export function looksLikePulumiYaml(content) {
|
|
71
133
|
return (/^\s*resources\s*:/m.test(content) &&
|
|
72
134
|
/^\s+type\s*:\s*[A-Za-z0-9_.-]+:\S+/m.test(content));
|
|
@@ -84,6 +146,24 @@ export function parseIac(content, requested = "auto") {
|
|
|
84
146
|
(requested === "auto" && looksLikeGithubWorkflow(content))) {
|
|
85
147
|
return parseGithubWorkflow(content);
|
|
86
148
|
}
|
|
149
|
+
if (requested === "ansible" ||
|
|
150
|
+
(requested === "auto" && looksLikeAnsible(content))) {
|
|
151
|
+
return parseAnsible(content);
|
|
152
|
+
}
|
|
153
|
+
if (requested === "helm" ||
|
|
154
|
+
(requested === "auto" && looksLikeHelm(content))) {
|
|
155
|
+
return parseHelm(content);
|
|
156
|
+
}
|
|
157
|
+
if (requested === "bicep" || (requested === "auto" && looksLikeBicep(content))) {
|
|
158
|
+
if (!looksLikeBicep(content))
|
|
159
|
+
throw new ScanInputError("A Bicep file requires a Microsoft resource declaration.");
|
|
160
|
+
return parseAzure(content, "bicep");
|
|
161
|
+
}
|
|
162
|
+
if (requested === "arm" || (requested === "auto" && looksLikeArm(content))) {
|
|
163
|
+
if (!looksLikeArm(content))
|
|
164
|
+
throw new ScanInputError("An ARM template requires a deploymentTemplate schema or contentVersion with Microsoft resources.");
|
|
165
|
+
return parseAzure(content, "arm");
|
|
166
|
+
}
|
|
87
167
|
if (requested !== "terraform" &&
|
|
88
168
|
requested !== "compose" &&
|
|
89
169
|
looksLikePulumiProgram(content) &&
|
|
@@ -93,8 +173,7 @@ export function parseIac(content, requested = "auto") {
|
|
|
93
173
|
const warnings = [];
|
|
94
174
|
const resources = [];
|
|
95
175
|
const isHcl = (requested === "terraform" && !content.trimStart().startsWith("{")) ||
|
|
96
|
-
(requested === "auto" &&
|
|
97
|
-
/^\s*(?:(?:resource|module|variable|provider|data|output)\s+"|(?:terraform|locals)\s*\{)/m.test(content));
|
|
176
|
+
(requested === "auto" && looksLikeTerraformHcl(content));
|
|
98
177
|
if (isHcl) {
|
|
99
178
|
let data;
|
|
100
179
|
try {
|
|
@@ -300,7 +379,7 @@ export function parseIac(content, requested = "auto") {
|
|
|
300
379
|
}
|
|
301
380
|
}
|
|
302
381
|
if (!format)
|
|
303
|
-
throw new ScanInputError("No IaC document found. Choose Terraform, CloudFormation, Kubernetes, Pulumi, Docker Compose, a Dockerfile,
|
|
382
|
+
throw new ScanInputError("No IaC document found. Choose Terraform, CloudFormation, Kubernetes, Pulumi, Docker Compose, a Dockerfile, a GitHub Actions workflow, a Helm template, an Ansible playbook, Bicep, or an ARM template.");
|
|
304
383
|
if (!resources.length)
|
|
305
384
|
warnings.push("No resources found in the submitted document.");
|
|
306
385
|
if (/(?:!(?:Ref|Sub|GetAtt|If)\b|"(?:Ref|Fn::\w+)"\s*:|\$\{)/.test(content))
|
|
@@ -452,6 +531,88 @@ function runPipesToShell(text) {
|
|
|
452
531
|
/\b(?:curl|wget)\b/i.test(text) &&
|
|
453
532
|
/\|\s*(?:sudo\s+)?(?:\S*\/)?(?:ba)?sh\b/i.test(text));
|
|
454
533
|
}
|
|
534
|
+
function copySources(text) {
|
|
535
|
+
if (!/^(?:COPY|ADD)\b/i.test(text))
|
|
536
|
+
return undefined;
|
|
537
|
+
const rest = text.replace(/^(?:COPY|ADD)\b/i, "").trim();
|
|
538
|
+
if (rest.startsWith("[")) {
|
|
539
|
+
try {
|
|
540
|
+
const parsed = JSON.parse(rest);
|
|
541
|
+
if (!Array.isArray(parsed) || parsed.length < 2)
|
|
542
|
+
return [];
|
|
543
|
+
return parsed.slice(0, -1).map((item) => String(item));
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
return [];
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const tokens = [];
|
|
550
|
+
for (const match of rest.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) {
|
|
551
|
+
const token = match[1] ?? match[2] ?? match[3];
|
|
552
|
+
if (token.startsWith("--"))
|
|
553
|
+
continue;
|
|
554
|
+
tokens.push(token);
|
|
555
|
+
}
|
|
556
|
+
if (tokens.length < 2)
|
|
557
|
+
return [];
|
|
558
|
+
return tokens.slice(0, -1);
|
|
559
|
+
}
|
|
560
|
+
function sourceIsSecret(source) {
|
|
561
|
+
const path = source.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
562
|
+
const base = path.split("/").filter(Boolean).at(-1) ?? path;
|
|
563
|
+
if (base === ".env" || base === "id_rsa" || base === "id_ed25519")
|
|
564
|
+
return true;
|
|
565
|
+
if (base.endsWith(".pem"))
|
|
566
|
+
return true;
|
|
567
|
+
return base === "credentials";
|
|
568
|
+
}
|
|
569
|
+
function secretVariableName(name) {
|
|
570
|
+
const normalized = name.replace(/-/g, "_");
|
|
571
|
+
return (/(?:^|_)(?:password|passwd|secret|token)$/i.test(normalized) ||
|
|
572
|
+
/(?:^|_)(?:api_?key|auth_token|access_key|private_key)$/i.test(normalized));
|
|
573
|
+
}
|
|
574
|
+
function isShellReference(value) {
|
|
575
|
+
return (/^\$[A-Za-z_][A-Za-z0-9_]*$/.test(value) ||
|
|
576
|
+
/^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(value));
|
|
577
|
+
}
|
|
578
|
+
function parseImageAssignments(kind, rest) {
|
|
579
|
+
const trimmed = rest.trim();
|
|
580
|
+
if (!trimmed)
|
|
581
|
+
return [];
|
|
582
|
+
if (kind === "ARG" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed))
|
|
583
|
+
return [{ name: trimmed, value: "" }];
|
|
584
|
+
if (!trimmed.includes("=")) {
|
|
585
|
+
const spaced = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s+([\s\S]*)$/);
|
|
586
|
+
if (!spaced)
|
|
587
|
+
return [];
|
|
588
|
+
return [{ name: spaced[1], value: unquoteAssignment(spaced[2].trim()) }];
|
|
589
|
+
}
|
|
590
|
+
const pairs = [];
|
|
591
|
+
const pattern = /([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"]*)"|'([^']*)'|([^\s]*))/g;
|
|
592
|
+
for (const match of trimmed.matchAll(pattern)) {
|
|
593
|
+
pairs.push({
|
|
594
|
+
name: match[1],
|
|
595
|
+
value: match[2] ?? match[3] ?? match[4] ?? "",
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
return pairs;
|
|
599
|
+
}
|
|
600
|
+
function unquoteAssignment(value) {
|
|
601
|
+
if ((value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
|
|
602
|
+
(value.startsWith("'") && value.endsWith("'") && value.length >= 2))
|
|
603
|
+
return value.slice(1, -1);
|
|
604
|
+
return value;
|
|
605
|
+
}
|
|
606
|
+
function workflowEnvIsLiteral(value) {
|
|
607
|
+
const trimmed = value.trim();
|
|
608
|
+
if (!trimmed)
|
|
609
|
+
return false;
|
|
610
|
+
if (/^\$\{\{\s*secrets\.[^}]+\}\}$/i.test(trimmed))
|
|
611
|
+
return false;
|
|
612
|
+
if (/^\$\{\{\s*github\.token\s*\}\}$/i.test(trimmed))
|
|
613
|
+
return false;
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
455
616
|
function lineLocation(line, path) {
|
|
456
617
|
return { line, column: 1, path };
|
|
457
618
|
}
|
|
@@ -500,6 +661,33 @@ function parseDockerfile(content) {
|
|
|
500
661
|
locate: () => lineLocation(instruction.line, "run"),
|
|
501
662
|
});
|
|
502
663
|
});
|
|
664
|
+
instructions.forEach((instruction, index) => {
|
|
665
|
+
const sources = copySources(instruction.text);
|
|
666
|
+
if (!sources)
|
|
667
|
+
return;
|
|
668
|
+
resources.push({
|
|
669
|
+
id: `dockerfile.copy.${index + 1}`,
|
|
670
|
+
type: "dockerfile:copy",
|
|
671
|
+
value: { secret: sources.some(sourceIsSecret) },
|
|
672
|
+
locate: () => lineLocation(instruction.line, "copy"),
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
instructions.forEach((instruction, index) => {
|
|
676
|
+
const header = instruction.text.match(/^(ENV|ARG)\b\s*([\s\S]*)$/i);
|
|
677
|
+
if (!header)
|
|
678
|
+
return;
|
|
679
|
+
const kind = header[1].toUpperCase() === "ARG" ? "ARG" : "ENV";
|
|
680
|
+
const assignments = parseImageAssignments(kind, header[2] ?? "");
|
|
681
|
+
const literal = assignments.some((item) => secretVariableName(item.name) &&
|
|
682
|
+
item.value.trim() !== "" &&
|
|
683
|
+
!isShellReference(item.value.trim()));
|
|
684
|
+
resources.push({
|
|
685
|
+
id: `dockerfile.env.${index + 1}`,
|
|
686
|
+
type: "dockerfile:env",
|
|
687
|
+
value: { literal },
|
|
688
|
+
locate: () => lineLocation(instruction.line, "env"),
|
|
689
|
+
});
|
|
690
|
+
});
|
|
503
691
|
return {
|
|
504
692
|
format: "dockerfile",
|
|
505
693
|
resources,
|
|
@@ -526,6 +714,14 @@ function workflowChecksOutHead(jobs) {
|
|
|
526
714
|
}
|
|
527
715
|
return false;
|
|
528
716
|
}
|
|
717
|
+
function runPrintsSecret(script) {
|
|
718
|
+
const logical = script.replace(/\\\r?\n\s*/g, " ");
|
|
719
|
+
return logical.split(/\n/).some((line) => {
|
|
720
|
+
const code = line.replace(/(^|\s)#[^\n]*$/, "");
|
|
721
|
+
return (/\b(?:echo|printf|print)\b/i.test(code) &&
|
|
722
|
+
/\$\{\{\s*secrets\./.test(code));
|
|
723
|
+
});
|
|
724
|
+
}
|
|
529
725
|
function permissionsAreBroad(top, jobs, pullRequestTarget) {
|
|
530
726
|
const values = [
|
|
531
727
|
top,
|
|
@@ -594,7 +790,27 @@ function parseGithubWorkflow(content) {
|
|
|
594
790
|
value: { broad },
|
|
595
791
|
locate: () => position(root.permissions != null ? ["permissions"] : ["on"]),
|
|
596
792
|
});
|
|
793
|
+
const addEnv = (envValue, path) => {
|
|
794
|
+
for (const [key, raw] of Object.entries(object(envValue))) {
|
|
795
|
+
if (typeof raw !== "string" && raw != null)
|
|
796
|
+
continue;
|
|
797
|
+
if (!secretVariableName(key))
|
|
798
|
+
continue;
|
|
799
|
+
const value = typeof raw === "string" ? raw : "";
|
|
800
|
+
resources.push({
|
|
801
|
+
id: `env.${[...path, key].join(".")}`,
|
|
802
|
+
type: "github:env",
|
|
803
|
+
value: { literal: workflowEnvIsLiteral(value) },
|
|
804
|
+
locate: () => position([...path, key]),
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
addEnv(root.env, ["env"]);
|
|
597
809
|
for (const [jobName, job] of Object.entries(object(root.jobs))) {
|
|
810
|
+
addEnv(object(job).env, ["jobs", jobName, "env"]);
|
|
811
|
+
array(object(job).steps).forEach((step, index) => {
|
|
812
|
+
addEnv(object(step).env, ["jobs", jobName, "steps", index, "env"]);
|
|
813
|
+
});
|
|
598
814
|
array(object(job).steps).forEach((step, index) => {
|
|
599
815
|
const uses = object(step).uses;
|
|
600
816
|
if (typeof uses !== "string")
|
|
@@ -609,6 +825,17 @@ function parseGithubWorkflow(content) {
|
|
|
609
825
|
locate: () => position(["jobs", jobName, "steps", index, "uses"]),
|
|
610
826
|
});
|
|
611
827
|
});
|
|
828
|
+
array(object(job).steps).forEach((step, index) => {
|
|
829
|
+
const run = object(step).run;
|
|
830
|
+
if (typeof run !== "string")
|
|
831
|
+
return;
|
|
832
|
+
resources.push({
|
|
833
|
+
id: `jobs.${jobName}.steps.${index}.run`,
|
|
834
|
+
type: "github:run",
|
|
835
|
+
value: { printsSecret: runPrintsSecret(run) },
|
|
836
|
+
locate: () => position(["jobs", jobName, "steps", index, "run"]),
|
|
837
|
+
});
|
|
838
|
+
});
|
|
612
839
|
}
|
|
613
840
|
}
|
|
614
841
|
if (!found)
|
|
@@ -649,3 +876,197 @@ function pulumiResourceMap(root) {
|
|
|
649
876
|
function escapeRegex(value) {
|
|
650
877
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
651
878
|
}
|
|
879
|
+
function maskNonCode(content) {
|
|
880
|
+
const withoutBlockComments = content
|
|
881
|
+
.replace(/\{\{-?\s*\/\*[\s\S]*?\*\/\s*-?\}\}/g, (match) => match.replace(/[^\n]/g, " "))
|
|
882
|
+
.replace(/\{#[\s\S]*?#\}/g, (match) => match.replace(/[^\n]/g, " "));
|
|
883
|
+
return withoutBlockComments
|
|
884
|
+
.split("\n")
|
|
885
|
+
.map((line) => {
|
|
886
|
+
let out = "";
|
|
887
|
+
let templates = 0;
|
|
888
|
+
let quote;
|
|
889
|
+
for (let index = 0; index < line.length; index++) {
|
|
890
|
+
const char = line[index];
|
|
891
|
+
const next = line[index + 1];
|
|
892
|
+
if (quote) {
|
|
893
|
+
out += char;
|
|
894
|
+
if (char === quote && line[index - 1] !== "\\")
|
|
895
|
+
quote = undefined;
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (templates === 0 && (char === '"' || char === "'")) {
|
|
899
|
+
quote = char;
|
|
900
|
+
out += char;
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
if (char === "{" && next === "{") {
|
|
904
|
+
templates += 1;
|
|
905
|
+
out += char;
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
if (char === "}" && next === "}" && templates > 0) {
|
|
909
|
+
templates -= 1;
|
|
910
|
+
out += char;
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
if (templates === 0 && char === "#") {
|
|
914
|
+
return out + " ".repeat(line.length - out.length);
|
|
915
|
+
}
|
|
916
|
+
out += char;
|
|
917
|
+
}
|
|
918
|
+
return out;
|
|
919
|
+
})
|
|
920
|
+
.join("\n");
|
|
921
|
+
}
|
|
922
|
+
function lineAt(content, index) {
|
|
923
|
+
return content.slice(0, index).split("\n").length;
|
|
924
|
+
}
|
|
925
|
+
function firstLiteral(content, pattern) {
|
|
926
|
+
const match = pattern.exec(content);
|
|
927
|
+
if (!match || match.index === undefined)
|
|
928
|
+
return { present: false, line: 1 };
|
|
929
|
+
return { present: true, line: lineAt(content, match.index) };
|
|
930
|
+
}
|
|
931
|
+
function literalResource(id, type, match, path) {
|
|
932
|
+
return {
|
|
933
|
+
id,
|
|
934
|
+
type,
|
|
935
|
+
value: { present: match.present },
|
|
936
|
+
locate: () => lineLocation(match.line, path),
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
const helmTemplateWarning = "Helm templates are not executed. Only literal text outside comments is checked.";
|
|
940
|
+
const ansibleFactsWarning = "Ansible facts are not executed. Only literal text outside comments is checked.";
|
|
941
|
+
function parseHelm(content) {
|
|
942
|
+
const visible = maskNonCode(content);
|
|
943
|
+
const privileged = firstLiteral(visible, /privileged\s*:\s*["']?true["']?\b/);
|
|
944
|
+
const hostNetwork = firstLiteral(visible, /hostNetwork\s*:\s*["']?true["']?\b/);
|
|
945
|
+
const latest = firstLiteral(visible, /:latest\b/);
|
|
946
|
+
const socket = firstLiteral(visible, /(?:hostPath|volumes?)\b[\s\S]{0,500}docker\.sock|docker\.sock[\s\S]{0,200}\b(?:hostPath|volumes?)\b/);
|
|
947
|
+
return {
|
|
948
|
+
format: "helm",
|
|
949
|
+
resources: [
|
|
950
|
+
literalResource("helm.privileged", "helm:privileged", privileged, "privileged"),
|
|
951
|
+
literalResource("helm.hostNetwork", "helm:hostNetwork", hostNetwork, "hostNetwork"),
|
|
952
|
+
literalResource("helm.image", "helm:image", latest, "image"),
|
|
953
|
+
literalResource("helm.socket", "helm:socket", socket, "volume"),
|
|
954
|
+
],
|
|
955
|
+
warnings: [helmTemplateWarning],
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
function parseAnsible(content) {
|
|
959
|
+
const visible = maskNonCode(content);
|
|
960
|
+
const become = firstLiteral(visible, /(?:^|\n)\s*(?:-\s+)?become\s*:\s*["']?(?:true|yes)["']?(?=\s|$)/);
|
|
961
|
+
const cidr = firstLiteral(visible, /(?:cidr|source)\s*:\s*(?:\|\s*)?(?:\n\s*-\s*)?["']?0\.0\.0\.0\/0\b/);
|
|
962
|
+
return {
|
|
963
|
+
format: "ansible",
|
|
964
|
+
resources: [
|
|
965
|
+
literalResource("ansible.become", "ansible:become", become, "become"),
|
|
966
|
+
literalResource("ansible.cidr", "ansible:cidr", cidr, "cidr"),
|
|
967
|
+
],
|
|
968
|
+
warnings: [ansibleFactsWarning],
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
function maskIaCComments(content) {
|
|
972
|
+
const withoutBlock = content.replace(/\/\*[\s\S]*?\*\//g, (match) => match.replace(/[^\n]/g, " "));
|
|
973
|
+
return withoutBlock
|
|
974
|
+
.split("\n")
|
|
975
|
+
.map((line) => {
|
|
976
|
+
let out = "";
|
|
977
|
+
let quote;
|
|
978
|
+
for (let index = 0; index < line.length; index++) {
|
|
979
|
+
const char = line[index];
|
|
980
|
+
const next = line[index + 1];
|
|
981
|
+
if (quote) {
|
|
982
|
+
out += char;
|
|
983
|
+
if (char === "\\" && quote === '"') {
|
|
984
|
+
if (next)
|
|
985
|
+
out += next;
|
|
986
|
+
index += 1;
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
if (char === quote)
|
|
990
|
+
quote = undefined;
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
if (char === '"' || char === "'") {
|
|
994
|
+
quote = char;
|
|
995
|
+
out += char;
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
if ((char === "/" && next === "/") ||
|
|
999
|
+
char === "#") {
|
|
1000
|
+
return out + " ".repeat(line.length - out.length);
|
|
1001
|
+
}
|
|
1002
|
+
out += char;
|
|
1003
|
+
}
|
|
1004
|
+
return out;
|
|
1005
|
+
})
|
|
1006
|
+
.join("\n");
|
|
1007
|
+
}
|
|
1008
|
+
function addressValueIsOpen(after) {
|
|
1009
|
+
return /(?:^|[\s\[,])(?:"\*"|"0\.0\.0\.0\/0"|'\*'|'0\.0\.0\.0\/0'|\*|0\.0\.0\.0\/0)(?=$|[\s,\]}"'])/.test(after);
|
|
1010
|
+
}
|
|
1011
|
+
function nearestScopeIsEgress(content, index) {
|
|
1012
|
+
const windowStart = Math.max(0, index - 800);
|
|
1013
|
+
const windowEnd = Math.min(content.length, index + 400);
|
|
1014
|
+
const slice = content.slice(windowStart, windowEnd);
|
|
1015
|
+
const relative = index - windowStart;
|
|
1016
|
+
let nearest;
|
|
1017
|
+
for (const match of slice.matchAll(/\b(Inbound|Outbound|ingress|egress)\b/gi)) {
|
|
1018
|
+
const at = match.index ?? 0;
|
|
1019
|
+
const distance = Math.abs(at - relative);
|
|
1020
|
+
const egress = /^(?:outbound|egress)$/i.test(match[1] ?? "");
|
|
1021
|
+
if (!nearest || distance < nearest.distance)
|
|
1022
|
+
nearest = { distance, egress };
|
|
1023
|
+
}
|
|
1024
|
+
return nearest?.egress ?? false;
|
|
1025
|
+
}
|
|
1026
|
+
function firstOpenAddress(content) {
|
|
1027
|
+
const pattern = /(source|destination)AddressPrefix(es)?["']?\s*[:=]/gi;
|
|
1028
|
+
for (const match of content.matchAll(pattern)) {
|
|
1029
|
+
const kind = (match[1] ?? "").toLowerCase();
|
|
1030
|
+
const index = match.index ?? 0;
|
|
1031
|
+
const after = content.slice(index + match[0].length, index + match[0].length + 240);
|
|
1032
|
+
if (!addressValueIsOpen(after))
|
|
1033
|
+
continue;
|
|
1034
|
+
if (kind === "destination" && nearestScopeIsEgress(content, index))
|
|
1035
|
+
continue;
|
|
1036
|
+
return { present: true, line: lineAt(content, index) };
|
|
1037
|
+
}
|
|
1038
|
+
return { present: false, line: 1 };
|
|
1039
|
+
}
|
|
1040
|
+
function enclosingAzureType(content, index) {
|
|
1041
|
+
const before = content.slice(Math.max(0, index - 4000), index);
|
|
1042
|
+
const declarations = [
|
|
1043
|
+
...before.matchAll(/resource\s+[A-Za-z_][\w]*\s+['"](Microsoft\.[^'"]+)['"]|(?:["']type["']|type)\s*:\s*["'](Microsoft\.[^"']+)["']/g),
|
|
1044
|
+
];
|
|
1045
|
+
const last = declarations.at(-1);
|
|
1046
|
+
return last?.[1] ?? last?.[2] ?? "";
|
|
1047
|
+
}
|
|
1048
|
+
function firstPublicNetwork(content) {
|
|
1049
|
+
const pattern = /publicNetworkAccess["']?\s*[:=]\s*(["']?)(Enabled|true|Disabled|false)\1(?![\w.-])/gi;
|
|
1050
|
+
for (const match of content.matchAll(pattern)) {
|
|
1051
|
+
if (!/^(?:enabled|true)$/i.test(match[2] ?? ""))
|
|
1052
|
+
continue;
|
|
1053
|
+
const index = match.index ?? 0;
|
|
1054
|
+
const type = enclosingAzureType(content, index);
|
|
1055
|
+
if (!/(?:database|storage|server)/i.test(type))
|
|
1056
|
+
continue;
|
|
1057
|
+
return { present: true, line: lineAt(content, index) };
|
|
1058
|
+
}
|
|
1059
|
+
return { present: false, line: 1 };
|
|
1060
|
+
}
|
|
1061
|
+
const azureExpressionWarning = "Bicep modules and ARM expressions are not evaluated.";
|
|
1062
|
+
function parseAzure(content, format) {
|
|
1063
|
+
const visible = maskIaCComments(content);
|
|
1064
|
+
return {
|
|
1065
|
+
format,
|
|
1066
|
+
resources: [
|
|
1067
|
+
literalResource("azure.exposure", "azure:exposure", firstOpenAddress(visible), "sourceAddressPrefix"),
|
|
1068
|
+
literalResource("azure.publicNetwork", "azure:publicNetwork", firstPublicNetwork(visible), "publicNetworkAccess"),
|
|
1069
|
+
],
|
|
1070
|
+
warnings: [azureExpressionWarning],
|
|
1071
|
+
};
|
|
1072
|
+
}
|
package/dist/iac/rules.js
CHANGED
|
@@ -239,6 +239,90 @@ export const rules = {
|
|
|
239
239
|
detail: "A uses value is pinned to @main or @master.",
|
|
240
240
|
remediation: "Pin the action to a release tag or a full commit SHA.",
|
|
241
241
|
},
|
|
242
|
+
DF004: {
|
|
243
|
+
category: "security",
|
|
244
|
+
severity: "high",
|
|
245
|
+
title: "Secret file copied into the image",
|
|
246
|
+
detail: "A COPY or ADD instruction uses a secret file as a source.",
|
|
247
|
+
remediation: "Keep secret files out of the image. Mount them at runtime from a secret store.",
|
|
248
|
+
},
|
|
249
|
+
GH004: {
|
|
250
|
+
category: "security",
|
|
251
|
+
severity: "high",
|
|
252
|
+
title: "Workflow prints a secret",
|
|
253
|
+
detail: "A run script echoes or prints a secrets expression.",
|
|
254
|
+
remediation: "Remove the printed secret from the script. Pass secrets through an environment variable that the step does not print.",
|
|
255
|
+
},
|
|
256
|
+
DF005: {
|
|
257
|
+
category: "security",
|
|
258
|
+
severity: "high",
|
|
259
|
+
title: "Secret assigned in an image variable",
|
|
260
|
+
detail: "A Dockerfile ENV or ARG assigns a non-empty literal to a secret-like name.",
|
|
261
|
+
remediation: "Pass the value at runtime or from a secret mount. Do not bake a literal secret into ENV or ARG.",
|
|
262
|
+
},
|
|
263
|
+
GH005: {
|
|
264
|
+
category: "security",
|
|
265
|
+
severity: "high",
|
|
266
|
+
title: "Secret assigned in workflow env",
|
|
267
|
+
detail: "A workflow env mapping assigns a non-empty literal to a secret-like name.",
|
|
268
|
+
remediation: "Reference secrets or github.token in the env value. Do not commit a literal secret.",
|
|
269
|
+
},
|
|
270
|
+
AZ001: {
|
|
271
|
+
category: "security",
|
|
272
|
+
severity: "high",
|
|
273
|
+
title: "Broad network exposure",
|
|
274
|
+
detail: "A source address prefix allows every address, or a destination prefix is open outside an egress scope.",
|
|
275
|
+
remediation: "Restrict source address prefixes to approved ranges. Keep open destination prefixes on egress rules only.",
|
|
276
|
+
},
|
|
277
|
+
AZ002: {
|
|
278
|
+
category: "security",
|
|
279
|
+
severity: "high",
|
|
280
|
+
title: "Database or storage allows public network access",
|
|
281
|
+
detail: "publicNetworkAccess is Enabled or true on a database, storage, or server resource.",
|
|
282
|
+
remediation: "Set publicNetworkAccess to Disabled and reach the resource through a private endpoint.",
|
|
283
|
+
},
|
|
284
|
+
HELM001: {
|
|
285
|
+
category: "security",
|
|
286
|
+
severity: "critical",
|
|
287
|
+
title: "Privileged container in a Helm template",
|
|
288
|
+
detail: "The template sets privileged to true.",
|
|
289
|
+
remediation: "Set privileged to false. Isolate workloads that genuinely require elevated privileges.",
|
|
290
|
+
},
|
|
291
|
+
HELM002: {
|
|
292
|
+
category: "security",
|
|
293
|
+
severity: "high",
|
|
294
|
+
title: "Host network in a Helm template",
|
|
295
|
+
detail: "The template sets hostNetwork to true.",
|
|
296
|
+
remediation: "Disable hostNetwork unless a reviewed node-level component requires it.",
|
|
297
|
+
},
|
|
298
|
+
HELM003: {
|
|
299
|
+
category: "reliability",
|
|
300
|
+
severity: "medium",
|
|
301
|
+
title: "Unpinned image in a Helm template",
|
|
302
|
+
detail: "An image tag uses latest.",
|
|
303
|
+
remediation: "Pin an immutable digest or a release tag.",
|
|
304
|
+
},
|
|
305
|
+
HELM004: {
|
|
306
|
+
category: "security",
|
|
307
|
+
severity: "high",
|
|
308
|
+
title: "Docker socket mounted in a Helm template",
|
|
309
|
+
detail: "A hostPath or volume contains docker.sock.",
|
|
310
|
+
remediation: "Remove the Docker socket mount. Use a scoped volume or a reviewed runtime API instead.",
|
|
311
|
+
},
|
|
312
|
+
ANS001: {
|
|
313
|
+
category: "security",
|
|
314
|
+
severity: "high",
|
|
315
|
+
title: "Ansible task runs as root",
|
|
316
|
+
detail: "A play or task sets become to true or yes.",
|
|
317
|
+
remediation: "Set become to false unless the task has a reviewed need to run as root.",
|
|
318
|
+
},
|
|
319
|
+
ANS002: {
|
|
320
|
+
category: "security",
|
|
321
|
+
severity: "high",
|
|
322
|
+
title: "Broad network exposure",
|
|
323
|
+
detail: "A cidr or source allows 0.0.0.0/0.",
|
|
324
|
+
remediation: "Restrict the cidr or source to approved addresses.",
|
|
325
|
+
},
|
|
242
326
|
};
|
|
243
327
|
export function resourceFindings(resources, format) {
|
|
244
328
|
const findings = [];
|
|
@@ -262,6 +346,18 @@ export function resourceFindings(resources, format) {
|
|
|
262
346
|
checkGithub(resource, check);
|
|
263
347
|
continue;
|
|
264
348
|
}
|
|
349
|
+
if (format === "helm") {
|
|
350
|
+
checkHelm(resource, check);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (format === "ansible") {
|
|
354
|
+
checkAnsible(resource, check);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (format === "bicep" || format === "arm") {
|
|
358
|
+
checkAzure(resource, check);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
265
361
|
walk(resource.value, (key, value, path, parent) => {
|
|
266
362
|
if (typeof value !== "string")
|
|
267
363
|
return;
|
|
@@ -289,6 +385,10 @@ function checkDockerfile(resource, check) {
|
|
|
289
385
|
check("DF002", resource.value.unpinned === true, ["image"]);
|
|
290
386
|
else if (resource.type === "dockerfile:run")
|
|
291
387
|
check("DF003", resource.value.piped === true, ["run"]);
|
|
388
|
+
else if (resource.type === "dockerfile:copy")
|
|
389
|
+
check("DF004", resource.value.secret === true, ["copy"]);
|
|
390
|
+
else if (resource.type === "dockerfile:env")
|
|
391
|
+
check("DF005", resource.value.literal === true, ["env"]);
|
|
292
392
|
}
|
|
293
393
|
function checkGithub(resource, check) {
|
|
294
394
|
if (resource.type === "github:trigger")
|
|
@@ -298,6 +398,32 @@ function checkGithub(resource, check) {
|
|
|
298
398
|
check("GH002", resource.value.broad === true, ["permissions"]);
|
|
299
399
|
else if (resource.type === "github:action")
|
|
300
400
|
check("GH003", resource.value.floating === true, ["uses"]);
|
|
401
|
+
else if (resource.type === "github:run")
|
|
402
|
+
check("GH004", resource.value.printsSecret === true, ["run"]);
|
|
403
|
+
else if (resource.type === "github:env")
|
|
404
|
+
check("GH005", resource.value.literal === true, ["env"]);
|
|
405
|
+
}
|
|
406
|
+
function checkHelm(resource, check) {
|
|
407
|
+
if (resource.type === "helm:privileged")
|
|
408
|
+
check("HELM001", resource.value.present === true, ["privileged"]);
|
|
409
|
+
else if (resource.type === "helm:hostNetwork")
|
|
410
|
+
check("HELM002", resource.value.present === true, ["hostNetwork"]);
|
|
411
|
+
else if (resource.type === "helm:image")
|
|
412
|
+
check("HELM003", resource.value.present === true, ["image"]);
|
|
413
|
+
else if (resource.type === "helm:socket")
|
|
414
|
+
check("HELM004", resource.value.present === true, ["volume"]);
|
|
415
|
+
}
|
|
416
|
+
function checkAnsible(resource, check) {
|
|
417
|
+
if (resource.type === "ansible:become")
|
|
418
|
+
check("ANS001", resource.value.present === true, ["become"]);
|
|
419
|
+
else if (resource.type === "ansible:cidr")
|
|
420
|
+
check("ANS002", resource.value.present === true, ["cidr"]);
|
|
421
|
+
}
|
|
422
|
+
function checkAzure(resource, check) {
|
|
423
|
+
if (resource.type === "azure:exposure")
|
|
424
|
+
check("AZ001", resource.value.present === true, ["sourceAddressPrefix"]);
|
|
425
|
+
else if (resource.type === "azure:publicNetwork")
|
|
426
|
+
check("AZ002", resource.value.present === true, ["publicNetworkAccess"]);
|
|
301
427
|
}
|
|
302
428
|
function walk(value, visit, path = []) {
|
|
303
429
|
if (Array.isArray(value))
|
package/dist/server.js
CHANGED
|
@@ -75,12 +75,16 @@ export function createServer(remote = false) {
|
|
|
75
75
|
"compose",
|
|
76
76
|
"dockerfile",
|
|
77
77
|
"github",
|
|
78
|
+
"helm",
|
|
79
|
+
"ansible",
|
|
80
|
+
"bicep",
|
|
81
|
+
"arm",
|
|
78
82
|
"application",
|
|
79
83
|
"auto",
|
|
80
84
|
])
|
|
81
85
|
.optional()
|
|
82
86
|
.default("auto")
|
|
83
|
-
.describe("Content format: terraform, cloudformation, kubernetes, pulumi, compose,
|
|
87
|
+
.describe("Content format: terraform, cloudformation, kubernetes, pulumi, compose, dockerfile, github, helm, ansible, bicep, arm, application, auto."),
|
|
84
88
|
},
|
|
85
89
|
}, async ({ content, focus, format, minSeverity, maxFindings }) => {
|
|
86
90
|
const botHints = fetchPublicBotHints();
|
|
@@ -128,7 +132,7 @@ export function createServer(remote = false) {
|
|
|
128
132
|
description: (name === "scan_iac_stub"
|
|
129
133
|
? "Compatibility alias for scan_iac; runs the full scanner. "
|
|
130
134
|
: "") +
|
|
131
|
-
"Parse Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes manifests, Pulumi YAML, Pulumi programs, or Docker Compose. Includes Dockerfile and
|
|
135
|
+
"Parse Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes manifests, Pulumi YAML, Pulumi programs, or Docker Compose. Includes Dockerfile, GitHub Actions, Helm templates, Ansible playbooks, Bicep files, and ARM templates. Helm templates, Ansible facts, Bicep modules, and ARM expressions are not executed. Check security, reliability and cost rules with resource locations and remediation. Pulumi programs are scanned as text.",
|
|
132
136
|
annotations: {
|
|
133
137
|
readOnlyHint: true,
|
|
134
138
|
destructiveHint: false,
|
|
@@ -136,7 +140,7 @@ export function createServer(remote = false) {
|
|
|
136
140
|
},
|
|
137
141
|
_meta: { securitySchemes: [{ type: "oauth2", scopes: ["mcp"] }] },
|
|
138
142
|
inputSchema: {
|
|
139
|
-
content: contentSchema.describe("Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes YAML/JSON, Pulumi YAML or program text, or Docker Compose. Includes Dockerfile and
|
|
143
|
+
content: contentSchema.describe("Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes YAML/JSON, Pulumi YAML or program text, or Docker Compose. Includes Dockerfile, GitHub Actions, Helm templates, Ansible playbooks, Bicep files, and ARM templates."),
|
|
140
144
|
format: z
|
|
141
145
|
.enum([
|
|
142
146
|
"terraform",
|
|
@@ -146,6 +150,10 @@ export function createServer(remote = false) {
|
|
|
146
150
|
"compose",
|
|
147
151
|
"dockerfile",
|
|
148
152
|
"github",
|
|
153
|
+
"helm",
|
|
154
|
+
"ansible",
|
|
155
|
+
"bicep",
|
|
156
|
+
"arm",
|
|
149
157
|
"auto",
|
|
150
158
|
])
|
|
151
159
|
.default("auto"),
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.6.
|
|
1
|
+
export declare const VERSION = "0.6.11";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.6.
|
|
1
|
+
export const VERSION = "0.6.11";
|