@skaleagents/swarm 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -3
- package/dist/iac/parse.d.ts +25 -0
- package/dist/iac/parse.js +239 -0
- package/dist/iac/rules.d.ts +16 -0
- package/dist/iac/rules.js +450 -0
- package/dist/iac/scan.d.ts +43 -0
- package/dist/iac/scan.js +59 -0
- package/dist/review.d.ts +11 -1
- package/dist/review.js +72 -11
- package/dist/server.js +114 -66
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -3,12 +3,54 @@
|
|
|
3
3
|
SkaleAgents MCP server with local stdio and hosted Streamable HTTP transports.
|
|
4
4
|
Uses browser OAuth for sign-in.
|
|
5
5
|
|
|
6
|
-
Tools: `review_architecture`, `scan_iac_stub
|
|
6
|
+
Tools: `review_architecture`, `scan_iac`. The older `scan_iac_stub` name remains
|
|
7
|
+
an alias for the full scanner.
|
|
7
8
|
|
|
8
9
|
`review_architecture` accepts application source or infrastructure text. Your AI
|
|
9
10
|
client reads the files in its workspace and sends the relevant content through
|
|
10
11
|
the MCP tool for a structured review.
|
|
11
12
|
|
|
13
|
+
## Infrastructure scanning
|
|
14
|
+
|
|
15
|
+
`scan_iac` parses Terraform HCL/JSON, CloudFormation YAML/JSON, and Kubernetes
|
|
16
|
+
manifests, including multi-document YAML and Kubernetes Lists. It returns a
|
|
17
|
+
resource inventory and findings with stable rule IDs, severity, property paths,
|
|
18
|
+
line locations, and remediation. Findings never include matched secret values.
|
|
19
|
+
|
|
20
|
+
Checks cover public ingress, wildcard IAM, public storage, encryption settings,
|
|
21
|
+
bucket versioning, RDS protection, EC2 metadata, Kubernetes privileges, images,
|
|
22
|
+
resource requests, probes, replicas, inline Secrets, and RBAC. Literal credential
|
|
23
|
+
and HTTP URL checks also run against parsed resource properties.
|
|
24
|
+
|
|
25
|
+
Example tool arguments:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"content": "resource \"aws_db_instance\" \"app\" { publicly_accessible = true }",
|
|
30
|
+
"format": "terraform",
|
|
31
|
+
"focus": "security",
|
|
32
|
+
"minSeverity": "medium",
|
|
33
|
+
"maxFindings": 100
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Both tools accept `focus` (`general`, `security`, `reliability`, or `cost`),
|
|
38
|
+
`minSeverity` (`info` through `critical`), and `maxFindings` (1 to 500, default
|
|
39
|
+
100). Content must contain 1 to 500,000 characters and cannot be whitespace.
|
|
40
|
+
`format` defaults to `auto`; only `review_architecture` accepts `application`.
|
|
41
|
+
|
|
42
|
+
Results are returned as JSON text and MCP `structuredContent`. `totalFindings`
|
|
43
|
+
and `totals` cover all findings matching the filters; `truncated` signals that
|
|
44
|
+
`maxFindings` limited the returned list. `rulesEvaluated` lists the IaC checks
|
|
45
|
+
that ran. Malformed input returns a tool error, not a clean scan.
|
|
46
|
+
|
|
47
|
+
IaC reviews use the same scanner through either tool. Application reviews use
|
|
48
|
+
text patterns. Neither mode inspects live infrastructure. Terraform expressions,
|
|
49
|
+
CloudFormation intrinsics, and external modules are not evaluated. HCL line
|
|
50
|
+
locations point to resource declarations; property paths identify the setting.
|
|
51
|
+
YAML aliases must be expanded before submission. Coverage limits are included
|
|
52
|
+
in every result. An empty finding list is not proof that a system is secure.
|
|
53
|
+
|
|
12
54
|
## Hosted connection
|
|
13
55
|
|
|
14
56
|
Use `https://skaleagents.com/mcp` in Claude Desktop or ChatGPT's custom connector
|
|
@@ -91,13 +133,13 @@ Dev without build:
|
|
|
91
133
|
"mcpServers": {
|
|
92
134
|
"skaleagents": {
|
|
93
135
|
"command": "npx",
|
|
94
|
-
|
|
136
|
+
"args": ["-y", "@skaleagents/swarm@0.5.0"]
|
|
95
137
|
}
|
|
96
138
|
}
|
|
97
139
|
}
|
|
98
140
|
```
|
|
99
141
|
|
|
100
|
-
Restart Cursor after saving. In Agent/Chat, tools should appear as `review_architecture` and `scan_iac_stub`.
|
|
142
|
+
Restart Cursor after saving. In Agent/Chat, tools should appear as `review_architecture`, `scan_iac`, and the compatibility alias `scan_iac_stub`.
|
|
101
143
|
|
|
102
144
|
## Claude Code
|
|
103
145
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type IacFormat = "terraform" | "cloudformation" | "kubernetes";
|
|
2
|
+
export type Path = (string | number)[];
|
|
3
|
+
export type Location = {
|
|
4
|
+
line: number;
|
|
5
|
+
column: number;
|
|
6
|
+
path: string;
|
|
7
|
+
};
|
|
8
|
+
export type Resource = {
|
|
9
|
+
id: string;
|
|
10
|
+
type: string;
|
|
11
|
+
value: Record<string, unknown>;
|
|
12
|
+
locate: (path?: Path) => Location;
|
|
13
|
+
};
|
|
14
|
+
export type ParsedIac = {
|
|
15
|
+
format: IacFormat;
|
|
16
|
+
resources: Resource[];
|
|
17
|
+
warnings: string[];
|
|
18
|
+
};
|
|
19
|
+
export declare class ScanInputError extends Error {
|
|
20
|
+
constructor(message: string);
|
|
21
|
+
}
|
|
22
|
+
export declare function object(value: unknown): Record<string, unknown>;
|
|
23
|
+
export declare function array(value: unknown): unknown[];
|
|
24
|
+
export declare function looksLikeIac(content: string): boolean;
|
|
25
|
+
export declare function parseIac(content: string, requested?: IacFormat | "auto"): ParsedIac;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import hcl from "hcl2-parser";
|
|
2
|
+
import { LineCounter, parseAllDocuments } from "yaml";
|
|
3
|
+
export class ScanInputError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "ScanInputError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function object(value) {
|
|
10
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
11
|
+
? value
|
|
12
|
+
: {};
|
|
13
|
+
}
|
|
14
|
+
export function array(value) {
|
|
15
|
+
return Array.isArray(value) ? value : value == null ? [] : [value];
|
|
16
|
+
}
|
|
17
|
+
// Reject deeply nested documents before rule traversal. Never return source text in errors.
|
|
18
|
+
function checkShape(value, depth = 0, budget = { remaining: 50_000 }) {
|
|
19
|
+
if (depth > 80 || --budget.remaining < 0) {
|
|
20
|
+
throw new ScanInputError("Document exceeds the nesting or node limit. Split it into smaller inputs.");
|
|
21
|
+
}
|
|
22
|
+
if (value && typeof value === "object") {
|
|
23
|
+
for (const child of Object.values(value))
|
|
24
|
+
checkShape(child, depth + 1, budget);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function looksLikeIac(content) {
|
|
28
|
+
if (content.trimStart().startsWith("{")) {
|
|
29
|
+
try {
|
|
30
|
+
const value = object(JSON.parse(content));
|
|
31
|
+
if (value.resource || value.Resources || (value.apiVersion && value.kind))
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* Other format detection still applies to incomplete input. */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return (/^\s*(?:resource|module|terraform|variable|provider)\s+["{]/m.test(content) ||
|
|
39
|
+
/^\s*(?:["']?Resources["']?\s*:|apiVersion\s*:)/m.test(content) ||
|
|
40
|
+
/^\s*\{\s*"(?:resource|Resources|apiVersion|AWSTemplateFormatVersion)"\s*:/.test(content));
|
|
41
|
+
}
|
|
42
|
+
export function parseIac(content, requested = "auto") {
|
|
43
|
+
if (!content.trim())
|
|
44
|
+
throw new ScanInputError("Content must not be empty or whitespace.");
|
|
45
|
+
const warnings = [];
|
|
46
|
+
const resources = [];
|
|
47
|
+
const isHcl = (requested === "terraform" && !content.trimStart().startsWith("{")) ||
|
|
48
|
+
(requested === "auto" &&
|
|
49
|
+
/^\s*(?:(?:resource|module|variable|provider|data|output)\s+"|(?:terraform|locals)\s*\{)/m.test(content));
|
|
50
|
+
if (isHcl) {
|
|
51
|
+
let data;
|
|
52
|
+
try {
|
|
53
|
+
const [parsed, error] = hcl.parseToObject(content);
|
|
54
|
+
if (error || !parsed)
|
|
55
|
+
throw new Error("parse");
|
|
56
|
+
data = parsed;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new ScanInputError("Invalid Terraform HCL. Check block syntax and attribute separators.");
|
|
60
|
+
}
|
|
61
|
+
checkShape(data);
|
|
62
|
+
// The HCL parser preserves expressions but does not expose source ranges.
|
|
63
|
+
// Report the resource declaration line and the exact parsed property path.
|
|
64
|
+
const searchable = content.replace(/\/\*[\s\S]*?\*\/|(?:#|\/\/)[^\n]*/g, (match) => match.replace(/[^\n]/g, " "));
|
|
65
|
+
for (const [type, instances] of Object.entries(object(object(data).resource))) {
|
|
66
|
+
for (const [name, blocks] of Object.entries(object(instances))) {
|
|
67
|
+
const declaration = new RegExp(`\\bresource\\s+"${escapeRegex(type)}"\\s+"${escapeRegex(name)}"`).exec(searchable);
|
|
68
|
+
const offset = declaration?.index ?? 0;
|
|
69
|
+
const before = content.slice(0, offset);
|
|
70
|
+
resources.push({
|
|
71
|
+
id: `${type}.${name}`,
|
|
72
|
+
type,
|
|
73
|
+
value: object(array(blocks)[0]),
|
|
74
|
+
locate: (path = []) => ({
|
|
75
|
+
line: before.split("\n").length,
|
|
76
|
+
column: offset - before.lastIndexOf("\n"),
|
|
77
|
+
path: ["resource", type, name, ...path].join("."),
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (object(data).module)
|
|
83
|
+
warnings.push("External Terraform modules are not expanded. Scan their source separately.");
|
|
84
|
+
if (JSON.stringify(data).includes("${"))
|
|
85
|
+
warnings.push("Terraform expressions are not evaluated. Findings use literal values and declared settings.");
|
|
86
|
+
if (!resources.length)
|
|
87
|
+
warnings.push("No resource declarations found. Variables, data sources, and outputs are not scanned as resources.");
|
|
88
|
+
return { format: "terraform", resources, warnings };
|
|
89
|
+
}
|
|
90
|
+
const lines = new LineCounter();
|
|
91
|
+
const tags = [
|
|
92
|
+
"Ref",
|
|
93
|
+
"Sub",
|
|
94
|
+
"GetAtt",
|
|
95
|
+
"Join",
|
|
96
|
+
"Select",
|
|
97
|
+
"Split",
|
|
98
|
+
"If",
|
|
99
|
+
"Equals",
|
|
100
|
+
"Not",
|
|
101
|
+
"And",
|
|
102
|
+
"Or",
|
|
103
|
+
"FindInMap",
|
|
104
|
+
"ImportValue",
|
|
105
|
+
"GetAZs",
|
|
106
|
+
"Base64",
|
|
107
|
+
"Cidr",
|
|
108
|
+
"Transform",
|
|
109
|
+
"Length",
|
|
110
|
+
"ToJsonString",
|
|
111
|
+
];
|
|
112
|
+
let documents;
|
|
113
|
+
try {
|
|
114
|
+
documents = parseAllDocuments(content, {
|
|
115
|
+
lineCounter: lines,
|
|
116
|
+
prettyErrors: false,
|
|
117
|
+
customTags: tags.flatMap((name) => ["scalar", "seq", "map"].map((kind) => ({
|
|
118
|
+
tag: `!${name}`,
|
|
119
|
+
...(kind === "scalar" ? {} : { collection: kind }),
|
|
120
|
+
resolve: () => ({ __intrinsic: name }),
|
|
121
|
+
}))),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
throw new ScanInputError("Invalid YAML or JSON document.");
|
|
126
|
+
}
|
|
127
|
+
let format = requested === "auto" ? undefined : requested;
|
|
128
|
+
for (const [documentIndex, doc] of documents.entries()) {
|
|
129
|
+
if (doc.errors.length || doc.warnings.length) {
|
|
130
|
+
const issue = doc.errors[0] ?? doc.warnings[0];
|
|
131
|
+
const line = lines.linePos(issue.pos[0]).line;
|
|
132
|
+
throw new ScanInputError(`Invalid or unsupported YAML/JSON syntax at line ${line}.`);
|
|
133
|
+
}
|
|
134
|
+
let data;
|
|
135
|
+
try {
|
|
136
|
+
data = doc.toJS({ maxAliasCount: 0 });
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
throw new ScanInputError("YAML aliases are not supported. Expand anchors before scanning.");
|
|
140
|
+
}
|
|
141
|
+
if (data == null)
|
|
142
|
+
continue;
|
|
143
|
+
checkShape(data);
|
|
144
|
+
const root = object(data);
|
|
145
|
+
const detected = root.Resources
|
|
146
|
+
? "cloudformation"
|
|
147
|
+
: root.apiVersion && root.kind
|
|
148
|
+
? "kubernetes"
|
|
149
|
+
: root.resource
|
|
150
|
+
? "terraform"
|
|
151
|
+
: undefined;
|
|
152
|
+
format ??= detected;
|
|
153
|
+
if (!format || (detected && detected !== format))
|
|
154
|
+
throw new ScanInputError("Input format is unsupported or mixed. Submit one IaC format per scan.");
|
|
155
|
+
const add = (id, type, value, prefix) => {
|
|
156
|
+
resources.push({
|
|
157
|
+
id,
|
|
158
|
+
type,
|
|
159
|
+
value: object(value),
|
|
160
|
+
locate: (path = []) => {
|
|
161
|
+
let node = doc.getIn([...prefix, ...path], true);
|
|
162
|
+
if (!node?.range)
|
|
163
|
+
node = doc.getIn(prefix, true);
|
|
164
|
+
const position = lines.linePos(node?.range?.[0] ?? doc.range?.[0] ?? 0);
|
|
165
|
+
return {
|
|
166
|
+
line: position.line,
|
|
167
|
+
column: position.col,
|
|
168
|
+
path: [documentIndex, ...prefix, ...path].join("."),
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
if (format === "cloudformation") {
|
|
174
|
+
if (!root.Resources ||
|
|
175
|
+
Array.isArray(root.Resources) ||
|
|
176
|
+
typeof root.Resources !== "object")
|
|
177
|
+
throw new ScanInputError("CloudFormation requires a Resources mapping.");
|
|
178
|
+
for (const [name, raw] of Object.entries(object(root.Resources))) {
|
|
179
|
+
const value = object(raw);
|
|
180
|
+
if (typeof value.Type !== "string")
|
|
181
|
+
throw new ScanInputError("Each CloudFormation resource requires a Type.");
|
|
182
|
+
add(name, value.Type, value, ["Resources", name]);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (format === "terraform") {
|
|
186
|
+
if (!root.resource ||
|
|
187
|
+
Array.isArray(root.resource) ||
|
|
188
|
+
typeof root.resource !== "object")
|
|
189
|
+
throw new ScanInputError("Terraform JSON requires a resource mapping.");
|
|
190
|
+
for (const [type, instances] of Object.entries(object(root.resource))) {
|
|
191
|
+
for (const [name, value] of Object.entries(object(instances)))
|
|
192
|
+
add(`${type}.${name}`, type, value, ["resource", type, name]);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
const manifests = root.kind === "List" ? array(root.items) : [root];
|
|
197
|
+
for (const [index, raw] of manifests.entries()) {
|
|
198
|
+
const value = object(raw);
|
|
199
|
+
if (typeof value.apiVersion !== "string" ||
|
|
200
|
+
typeof value.kind !== "string")
|
|
201
|
+
throw new ScanInputError("Each Kubernetes manifest requires apiVersion and kind.");
|
|
202
|
+
if ([
|
|
203
|
+
"Pod",
|
|
204
|
+
"Deployment",
|
|
205
|
+
"StatefulSet",
|
|
206
|
+
"DaemonSet",
|
|
207
|
+
"ReplicaSet",
|
|
208
|
+
"ReplicationController",
|
|
209
|
+
"Job",
|
|
210
|
+
"CronJob",
|
|
211
|
+
].includes(value.kind)) {
|
|
212
|
+
let spec = object(value.spec);
|
|
213
|
+
if (value.kind === "CronJob")
|
|
214
|
+
spec = object(object(spec.jobTemplate).spec);
|
|
215
|
+
if (value.kind !== "Pod")
|
|
216
|
+
spec = object(object(spec.template).spec);
|
|
217
|
+
if (!Array.isArray(spec.containers) ||
|
|
218
|
+
!spec.containers.length ||
|
|
219
|
+
spec.containers.some((c) => typeof object(c).name !== "string" ||
|
|
220
|
+
typeof object(c).image !== "string")) {
|
|
221
|
+
throw new ScanInputError("Kubernetes workloads require a containers list with a name and image for each container.");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const metadata = object(value.metadata);
|
|
225
|
+
add(`${value.kind}/${metadata.namespace ?? "default"}/${metadata.name ?? metadata.generateName ?? "unnamed"}`, value.kind, value, root.kind === "List" ? ["items", index] : []);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (!format)
|
|
230
|
+
throw new ScanInputError("No IaC document found. Choose Terraform, CloudFormation, or Kubernetes.");
|
|
231
|
+
if (!resources.length)
|
|
232
|
+
warnings.push("No resources found in the submitted document.");
|
|
233
|
+
if (/(?:!(?:Ref|Sub|GetAtt|If)\b|"(?:Ref|Fn::\w+)"\s*:|\$\{)/.test(content))
|
|
234
|
+
warnings.push("Intrinsic functions and expressions are not evaluated. Only literal configuration is checked.");
|
|
235
|
+
return { format, resources, warnings };
|
|
236
|
+
}
|
|
237
|
+
function escapeRegex(value) {
|
|
238
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
239
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Resource } from "./parse.js";
|
|
2
|
+
import type { Finding, FindingSeverity } from "../review.js";
|
|
3
|
+
type Category = "security" | "reliability" | "cost";
|
|
4
|
+
type Rule = {
|
|
5
|
+
category: Category;
|
|
6
|
+
severity: FindingSeverity;
|
|
7
|
+
title: string;
|
|
8
|
+
detail: string;
|
|
9
|
+
remediation: string;
|
|
10
|
+
};
|
|
11
|
+
export declare const rules: Record<string, Rule>;
|
|
12
|
+
export declare function resourceFindings(resources: Resource[], format: string): {
|
|
13
|
+
findings: Finding[];
|
|
14
|
+
checked: Set<string>;
|
|
15
|
+
};
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { array, object } from "./parse.js";
|
|
2
|
+
import { externalHttp, literalCredential } from "../review.js";
|
|
3
|
+
export const rules = {
|
|
4
|
+
SEC001: {
|
|
5
|
+
category: "security",
|
|
6
|
+
severity: "high",
|
|
7
|
+
title: "Hardcoded credential-like value",
|
|
8
|
+
detail: "A credential property contains a literal value.",
|
|
9
|
+
remediation: "Move the value to a secret manager or runtime secret reference. Rotate any credential that has been exposed.",
|
|
10
|
+
},
|
|
11
|
+
SEC002: {
|
|
12
|
+
category: "security",
|
|
13
|
+
severity: "critical",
|
|
14
|
+
title: "Possible AWS access key",
|
|
15
|
+
detail: "A value matches an AWS access-key identifier pattern.",
|
|
16
|
+
remediation: "Remove the key from source, rotate the credential, and check repository history.",
|
|
17
|
+
},
|
|
18
|
+
SEC003: {
|
|
19
|
+
category: "security",
|
|
20
|
+
severity: "critical",
|
|
21
|
+
title: "Private key material in source",
|
|
22
|
+
detail: "A value contains a private-key block.",
|
|
23
|
+
remediation: "Revoke or rotate the key and load its replacement from a secret store.",
|
|
24
|
+
},
|
|
25
|
+
NET002: {
|
|
26
|
+
category: "security",
|
|
27
|
+
severity: "medium",
|
|
28
|
+
title: "Unencrypted HTTP endpoint",
|
|
29
|
+
detail: "A configured URL uses HTTP outside loopback.",
|
|
30
|
+
remediation: "Use HTTPS for service traffic and keep certificate verification enabled.",
|
|
31
|
+
},
|
|
32
|
+
NET001: {
|
|
33
|
+
category: "security",
|
|
34
|
+
severity: "high",
|
|
35
|
+
title: "Broad network exposure",
|
|
36
|
+
detail: "An ingress rule allows every IPv4 or IPv6 source.",
|
|
37
|
+
remediation: "Restrict ingress to approved CIDRs or source security groups. Keep intentional public web traffic behind an authenticated edge.",
|
|
38
|
+
},
|
|
39
|
+
IAM001: {
|
|
40
|
+
category: "security",
|
|
41
|
+
severity: "high",
|
|
42
|
+
title: "Wildcard permission detected",
|
|
43
|
+
detail: "An Allow statement grants wildcard actions or principals.",
|
|
44
|
+
remediation: "List the required actions and trusted principals explicitly, with resource and condition restrictions.",
|
|
45
|
+
},
|
|
46
|
+
DATA001: {
|
|
47
|
+
category: "security",
|
|
48
|
+
severity: "high",
|
|
49
|
+
title: "Public data access",
|
|
50
|
+
detail: "The storage configuration permits public access.",
|
|
51
|
+
remediation: "Remove public ACLs and policies. Enable all public-access blocks unless this is an intentional public asset bucket.",
|
|
52
|
+
},
|
|
53
|
+
DATA002: {
|
|
54
|
+
category: "security",
|
|
55
|
+
severity: "high",
|
|
56
|
+
title: "Storage encryption disabled",
|
|
57
|
+
detail: "Encryption at rest is explicitly disabled.",
|
|
58
|
+
remediation: "Enable encryption with a managed or customer-managed key and plan migration of existing unencrypted data.",
|
|
59
|
+
},
|
|
60
|
+
DATA003: {
|
|
61
|
+
category: "reliability",
|
|
62
|
+
severity: "medium",
|
|
63
|
+
title: "Object versioning not enabled",
|
|
64
|
+
detail: "This bucket has no enabled versioning configuration in the submitted input.",
|
|
65
|
+
remediation: "Enable bucket versioning to recover overwritten or deleted objects. Configure lifecycle retention for older versions.",
|
|
66
|
+
},
|
|
67
|
+
DB001: {
|
|
68
|
+
category: "security",
|
|
69
|
+
severity: "high",
|
|
70
|
+
title: "Database publicly accessible",
|
|
71
|
+
detail: "The database enables public network access.",
|
|
72
|
+
remediation: "Disable public accessibility and connect through private subnets and restricted security groups.",
|
|
73
|
+
},
|
|
74
|
+
DB002: {
|
|
75
|
+
category: "reliability",
|
|
76
|
+
severity: "high",
|
|
77
|
+
title: "Database backups disabled",
|
|
78
|
+
detail: "Automated database backup retention is set to zero.",
|
|
79
|
+
remediation: "Set a nonzero retention period and test restoration against recovery objectives.",
|
|
80
|
+
},
|
|
81
|
+
DB003: {
|
|
82
|
+
category: "reliability",
|
|
83
|
+
severity: "medium",
|
|
84
|
+
title: "Database deletion protection disabled",
|
|
85
|
+
detail: "Deletion protection is explicitly disabled.",
|
|
86
|
+
remediation: "Enable deletion protection for persistent databases and require a reviewed decommissioning process.",
|
|
87
|
+
},
|
|
88
|
+
DB004: {
|
|
89
|
+
category: "reliability",
|
|
90
|
+
severity: "medium",
|
|
91
|
+
title: "Database lacks multi-zone failover",
|
|
92
|
+
detail: "Multi-zone availability is explicitly disabled.",
|
|
93
|
+
remediation: "Enable multi-zone failover for workloads that require continued service during a zone failure.",
|
|
94
|
+
},
|
|
95
|
+
VM001: {
|
|
96
|
+
category: "security",
|
|
97
|
+
severity: "high",
|
|
98
|
+
title: "Instance metadata tokens not required",
|
|
99
|
+
detail: "This EC2 instance does not explicitly require IMDSv2 tokens.",
|
|
100
|
+
remediation: "Set metadata_options.http_tokens or MetadataOptions.HttpTokens to required. Verify the account-level metadata defaults too.",
|
|
101
|
+
},
|
|
102
|
+
COST001: {
|
|
103
|
+
category: "cost",
|
|
104
|
+
severity: "info",
|
|
105
|
+
title: "Compute sizing needs review",
|
|
106
|
+
detail: "The resource declares a compute size.",
|
|
107
|
+
remediation: "Compare CPU and memory utilization with the chosen size before changing capacity or purchasing commitments.",
|
|
108
|
+
},
|
|
109
|
+
K8S001: {
|
|
110
|
+
category: "security",
|
|
111
|
+
severity: "critical",
|
|
112
|
+
title: "Elevated container privileges",
|
|
113
|
+
detail: "A container enables privileged mode or privilege escalation.",
|
|
114
|
+
remediation: "Set privileged and allowPrivilegeEscalation to false. Isolate workloads that genuinely require elevated privileges.",
|
|
115
|
+
},
|
|
116
|
+
K8S002: {
|
|
117
|
+
category: "security",
|
|
118
|
+
severity: "high",
|
|
119
|
+
title: "Host namespace access",
|
|
120
|
+
detail: "The workload shares a host network, process, or IPC namespace.",
|
|
121
|
+
remediation: "Disable hostNetwork, hostPID, and hostIPC unless required by a reviewed node-level component.",
|
|
122
|
+
},
|
|
123
|
+
K8S003: {
|
|
124
|
+
category: "security",
|
|
125
|
+
severity: "high",
|
|
126
|
+
title: "Host filesystem mounted",
|
|
127
|
+
detail: "A volume mounts a host path into the workload.",
|
|
128
|
+
remediation: "Use a PersistentVolumeClaim or a scoped ephemeral volume instead of hostPath.",
|
|
129
|
+
},
|
|
130
|
+
K8S004: {
|
|
131
|
+
category: "security",
|
|
132
|
+
severity: "medium",
|
|
133
|
+
title: "Non-root execution not enforced",
|
|
134
|
+
detail: "Neither the container nor pod enforces runAsNonRoot, or the container selects UID 0.",
|
|
135
|
+
remediation: "Set runAsNonRoot to true and use a nonzero UID supported by the image.",
|
|
136
|
+
},
|
|
137
|
+
K8S005: {
|
|
138
|
+
category: "security",
|
|
139
|
+
severity: "medium",
|
|
140
|
+
title: "Writable container root filesystem",
|
|
141
|
+
detail: "The container does not enforce a read-only root filesystem.",
|
|
142
|
+
remediation: "Set readOnlyRootFilesystem to true and mount writable volumes only where needed.",
|
|
143
|
+
},
|
|
144
|
+
K8S006: {
|
|
145
|
+
category: "security",
|
|
146
|
+
severity: "high",
|
|
147
|
+
title: "Dangerous Linux capabilities",
|
|
148
|
+
detail: "The container adds ALL, SYS_ADMIN, NET_ADMIN, or SYS_PTRACE.",
|
|
149
|
+
remediation: "Drop ALL capabilities and add back only those required by the process.",
|
|
150
|
+
},
|
|
151
|
+
K8S007: {
|
|
152
|
+
category: "reliability",
|
|
153
|
+
severity: "medium",
|
|
154
|
+
title: "Unpinned container image",
|
|
155
|
+
detail: "The image has no version tag or uses latest.",
|
|
156
|
+
remediation: "Pin an immutable digest or a release tag with an immutability policy.",
|
|
157
|
+
},
|
|
158
|
+
K8S008: {
|
|
159
|
+
category: "reliability",
|
|
160
|
+
severity: "medium",
|
|
161
|
+
title: "Container resource bounds missing",
|
|
162
|
+
detail: "CPU or memory requests, or the memory limit, are missing.",
|
|
163
|
+
remediation: "Set measured CPU and memory requests and a memory limit. Review throttling before adding CPU limits.",
|
|
164
|
+
},
|
|
165
|
+
K8S009: {
|
|
166
|
+
category: "reliability",
|
|
167
|
+
severity: "medium",
|
|
168
|
+
title: "Readiness probe missing",
|
|
169
|
+
detail: "A serving container has no readiness probe.",
|
|
170
|
+
remediation: "Add a readiness probe that confirms the process can accept traffic.",
|
|
171
|
+
},
|
|
172
|
+
K8S010: {
|
|
173
|
+
category: "reliability",
|
|
174
|
+
severity: "medium",
|
|
175
|
+
title: "Liveness probe missing",
|
|
176
|
+
detail: "A long-running container has no liveness probe.",
|
|
177
|
+
remediation: "Add a liveness probe and, for slow startup, a startup probe. Avoid restarting on dependency outages.",
|
|
178
|
+
},
|
|
179
|
+
K8S011: {
|
|
180
|
+
category: "reliability",
|
|
181
|
+
severity: "medium",
|
|
182
|
+
title: "Single workload replica",
|
|
183
|
+
detail: "The workload requests fewer than two replicas and no matching autoscaler was submitted.",
|
|
184
|
+
remediation: "Use at least two replicas for availability-sensitive services and spread them across nodes or zones.",
|
|
185
|
+
},
|
|
186
|
+
K8S012: {
|
|
187
|
+
category: "security",
|
|
188
|
+
severity: "high",
|
|
189
|
+
title: "Secret stored in manifest",
|
|
190
|
+
detail: "A Secret manifest contains inline data. Base64 encoding does not protect credentials.",
|
|
191
|
+
remediation: "Load secret values from an external secret store and keep plaintext and base64-encoded credentials out of source control.",
|
|
192
|
+
},
|
|
193
|
+
K8S013: {
|
|
194
|
+
category: "security",
|
|
195
|
+
severity: "high",
|
|
196
|
+
title: "Wildcard Kubernetes RBAC",
|
|
197
|
+
detail: "A role grants wildcard verbs, resources, or API groups.",
|
|
198
|
+
remediation: "Scope the role to the required API groups, resource names, and verbs.",
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
export function resourceFindings(resources, format) {
|
|
202
|
+
const findings = [];
|
|
203
|
+
const checked = new Set();
|
|
204
|
+
for (const resource of resources) {
|
|
205
|
+
const check = (id, condition, path = []) => {
|
|
206
|
+
checked.add(id);
|
|
207
|
+
if (condition)
|
|
208
|
+
findings.push({
|
|
209
|
+
ruleId: id,
|
|
210
|
+
...rules[id],
|
|
211
|
+
resource: resource.id,
|
|
212
|
+
location: resource.locate(path),
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
walk(resource.value, (key, value, path, parent) => {
|
|
216
|
+
if (typeof value !== "string")
|
|
217
|
+
return;
|
|
218
|
+
const credentialName = /(?:password|passwd|secret|api[_-]?key|auth[_-]?token)$/i;
|
|
219
|
+
check("SEC001", (credentialName.test(key) ||
|
|
220
|
+
(key === "value" && credentialName.test(String(parent.name)))) &&
|
|
221
|
+
literalCredential(value), path);
|
|
222
|
+
check("SEC002", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/.test(value), path);
|
|
223
|
+
check("SEC003", /-----BEGIN (?:RSA |EC |OPENSSH |DSA |)PRIVATE KEY-----/.test(value), path);
|
|
224
|
+
check("NET002", [...value.matchAll(/\bhttp:\/\/[^\s"'<>]+/gi)].some((m) => externalHttp(m[0])), path);
|
|
225
|
+
});
|
|
226
|
+
if (format === "kubernetes")
|
|
227
|
+
checkKubernetes(resource, resources, check);
|
|
228
|
+
else
|
|
229
|
+
checkCloud(resource, resources, format, check);
|
|
230
|
+
}
|
|
231
|
+
return { findings, checked };
|
|
232
|
+
}
|
|
233
|
+
function walk(value, visit, path = []) {
|
|
234
|
+
if (Array.isArray(value))
|
|
235
|
+
value.forEach((child, index) => walk(child, visit, [...path, index]));
|
|
236
|
+
else
|
|
237
|
+
for (const [key, child] of Object.entries(object(value))) {
|
|
238
|
+
visit(key, child, [...path, key], object(value));
|
|
239
|
+
if (child && typeof child === "object")
|
|
240
|
+
walk(child, visit, [...path, key]);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function checkCloud(resource, resources, format, check) {
|
|
244
|
+
const tf = format === "terraform";
|
|
245
|
+
const value = tf ? resource.value : object(resource.value.Properties);
|
|
246
|
+
const prefix = tf ? [] : ["Properties"];
|
|
247
|
+
const property = (hcl, cfn) => value[tf ? hcl : cfn];
|
|
248
|
+
const path = (hcl, cfn) => [...prefix, tf ? hcl : cfn];
|
|
249
|
+
walk(value, (key, child, at, parent) => {
|
|
250
|
+
const isIngress = !at.some((part) => /^(?:egress|SecurityGroupEgress)$/i.test(String(part))) &&
|
|
251
|
+
!/egress/i.test(resource.type) &&
|
|
252
|
+
value.type !== "egress";
|
|
253
|
+
if (/^(?:cidr_blocks|ipv6_cidr_blocks|cidr_ipv4|cidr_ipv6|CidrIp|CidrIpv6)$/.test(key) &&
|
|
254
|
+
isIngress) {
|
|
255
|
+
check("NET001", array(child).some((cidr) => cidr === "0.0.0.0/0" || cidr === "::/0"), [...prefix, ...at]);
|
|
256
|
+
}
|
|
257
|
+
if (/^(?:Action|Principal|actions|principals)$/.test(key) &&
|
|
258
|
+
(parent.Effect === "Allow" || parent.effect === "Allow")) {
|
|
259
|
+
const values = typeof child === "object" && !Array.isArray(child)
|
|
260
|
+
? Object.values(object(child)).flatMap(array)
|
|
261
|
+
: array(child);
|
|
262
|
+
check("IAM001", values.some((item) => typeof item === "string" && item.includes("*")), [...prefix, ...at]);
|
|
263
|
+
}
|
|
264
|
+
if (/^(?:policy|assume_role_policy|PolicyDocument)$/.test(key) &&
|
|
265
|
+
typeof child === "string" &&
|
|
266
|
+
child.trimStart().startsWith("{")) {
|
|
267
|
+
try {
|
|
268
|
+
const document = JSON.parse(child);
|
|
269
|
+
for (const statement of array(object(document).Statement)) {
|
|
270
|
+
const s = object(statement);
|
|
271
|
+
check("IAM001", s.Effect === "Allow" &&
|
|
272
|
+
[
|
|
273
|
+
...array(s.Action),
|
|
274
|
+
...array(s.Principal),
|
|
275
|
+
...Object.values(object(s.Principal)).flatMap(array),
|
|
276
|
+
].some((v) => typeof v === "string" && v.includes("*")), [...prefix, ...at]);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
/* Nonliteral policies are covered by the expression warning. */
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (/^(?:instance_type|machine_type|vm_size|InstanceType|DBInstanceClass|instance_class)$/.test(key))
|
|
284
|
+
check("COST001", typeof child === "string", [...prefix, ...at]);
|
|
285
|
+
});
|
|
286
|
+
if (["aws_s3_bucket", "aws_s3_bucket_acl", "AWS::S3::Bucket"].includes(resource.type)) {
|
|
287
|
+
check("DATA001", [
|
|
288
|
+
"public-read",
|
|
289
|
+
"public-read-write",
|
|
290
|
+
"PublicRead",
|
|
291
|
+
"PublicReadWrite",
|
|
292
|
+
"AuthenticatedRead",
|
|
293
|
+
"authenticated-read",
|
|
294
|
+
].includes(String(property("acl", "AccessControl"))), path("acl", "AccessControl"));
|
|
295
|
+
}
|
|
296
|
+
if (["aws_s3_bucket", "AWS::S3::Bucket"].includes(resource.type)) {
|
|
297
|
+
const attached = resources.find((r) => r.type === "aws_s3_bucket_versioning" &&
|
|
298
|
+
r.value.bucket === `\${${resource.id}.id}`);
|
|
299
|
+
const config = tf
|
|
300
|
+
? object(array(value.versioning)[0])
|
|
301
|
+
: object(value.VersioningConfiguration);
|
|
302
|
+
const separate = object(array(attached?.value.versioning_configuration)[0]);
|
|
303
|
+
const enabled = tf
|
|
304
|
+
? config.enabled === true || separate.status === "Enabled"
|
|
305
|
+
: config.Status === "Enabled";
|
|
306
|
+
const dynamic = [config.enabled, config.Status, separate.status].some((v) => typeof v === "object" || (typeof v === "string" && v.includes("${")));
|
|
307
|
+
check("DATA003", !enabled && !dynamic, path("versioning", "VersioningConfiguration"));
|
|
308
|
+
}
|
|
309
|
+
if (["aws_s3_bucket_public_access_block", "AWS::S3::Bucket"].includes(resource.type)) {
|
|
310
|
+
const config = tf ? value : object(value.PublicAccessBlockConfiguration);
|
|
311
|
+
for (const key of tf
|
|
312
|
+
? [
|
|
313
|
+
"block_public_acls",
|
|
314
|
+
"block_public_policy",
|
|
315
|
+
"ignore_public_acls",
|
|
316
|
+
"restrict_public_buckets",
|
|
317
|
+
]
|
|
318
|
+
: [
|
|
319
|
+
"BlockPublicAcls",
|
|
320
|
+
"BlockPublicPolicy",
|
|
321
|
+
"IgnorePublicAcls",
|
|
322
|
+
"RestrictPublicBuckets",
|
|
323
|
+
]) {
|
|
324
|
+
check("DATA001", config[key] === false, tf ? [key] : ["Properties", "PublicAccessBlockConfiguration", key]);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if ([
|
|
328
|
+
"aws_db_instance",
|
|
329
|
+
"aws_rds_cluster",
|
|
330
|
+
"AWS::RDS::DBInstance",
|
|
331
|
+
"AWS::RDS::DBCluster",
|
|
332
|
+
].includes(resource.type)) {
|
|
333
|
+
check("DB001", property("publicly_accessible", "PubliclyAccessible") === true, path("publicly_accessible", "PubliclyAccessible"));
|
|
334
|
+
check("DATA002", property("storage_encrypted", "StorageEncrypted") === false, path("storage_encrypted", "StorageEncrypted"));
|
|
335
|
+
check("DB002", property("backup_retention_period", "BackupRetentionPeriod") === 0, path("backup_retention_period", "BackupRetentionPeriod"));
|
|
336
|
+
check("DB003", property("deletion_protection", "DeletionProtection") === false, path("deletion_protection", "DeletionProtection"));
|
|
337
|
+
check("DB004", property("multi_az", "MultiAZ") === false, path("multi_az", "MultiAZ"));
|
|
338
|
+
}
|
|
339
|
+
if (["aws_ebs_volume", "AWS::EC2::Volume"].includes(resource.type))
|
|
340
|
+
check("DATA002", property("encrypted", "Encrypted") === false, path("encrypted", "Encrypted"));
|
|
341
|
+
if (["aws_instance", "AWS::EC2::Instance"].includes(resource.type)) {
|
|
342
|
+
const metadata = tf
|
|
343
|
+
? object(array(value.metadata_options)[0])
|
|
344
|
+
: object(value.MetadataOptions);
|
|
345
|
+
const tokens = metadata[tf ? "http_tokens" : "HttpTokens"];
|
|
346
|
+
const endpoint = metadata[tf ? "http_endpoint" : "HttpEndpoint"];
|
|
347
|
+
check("VM001", endpoint !== "disabled" &&
|
|
348
|
+
(tokens === undefined || tokens === "optional"), path("metadata_options", "MetadataOptions"));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function checkKubernetes(resource, resources, check) {
|
|
352
|
+
const value = resource.value;
|
|
353
|
+
const spec = object(value.spec);
|
|
354
|
+
if (resource.type === "Secret")
|
|
355
|
+
check("K8S012", Object.keys(object(value.data)).length +
|
|
356
|
+
Object.keys(object(value.stringData)).length >
|
|
357
|
+
0);
|
|
358
|
+
if (["Role", "ClusterRole"].includes(resource.type)) {
|
|
359
|
+
array(value.rules).forEach((rule, index) => {
|
|
360
|
+
const r = object(rule);
|
|
361
|
+
check("K8S013", [
|
|
362
|
+
...array(r.verbs),
|
|
363
|
+
...array(r.resources),
|
|
364
|
+
...array(r.apiGroups),
|
|
365
|
+
].includes("*"), ["rules", index]);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
const workload = [
|
|
369
|
+
"Pod",
|
|
370
|
+
"Deployment",
|
|
371
|
+
"StatefulSet",
|
|
372
|
+
"DaemonSet",
|
|
373
|
+
"ReplicaSet",
|
|
374
|
+
"ReplicationController",
|
|
375
|
+
"Job",
|
|
376
|
+
"CronJob",
|
|
377
|
+
].includes(resource.type);
|
|
378
|
+
if (!workload)
|
|
379
|
+
return;
|
|
380
|
+
const prefix = resource.type === "Pod"
|
|
381
|
+
? ["spec"]
|
|
382
|
+
: resource.type === "CronJob"
|
|
383
|
+
? ["spec", "jobTemplate", "spec", "template", "spec"]
|
|
384
|
+
: ["spec", "template", "spec"];
|
|
385
|
+
let pod = value;
|
|
386
|
+
for (const key of prefix)
|
|
387
|
+
pod = object(pod)[key];
|
|
388
|
+
const podSpec = object(pod);
|
|
389
|
+
const podSecurity = object(podSpec.securityContext);
|
|
390
|
+
for (const key of ["hostNetwork", "hostPID", "hostIPC"])
|
|
391
|
+
check("K8S002", podSpec[key] === true, [...prefix, key]);
|
|
392
|
+
array(podSpec.volumes).forEach((volume, index) => check("K8S003", object(volume).hostPath != null, [
|
|
393
|
+
...prefix,
|
|
394
|
+
"volumes",
|
|
395
|
+
index,
|
|
396
|
+
"hostPath",
|
|
397
|
+
]));
|
|
398
|
+
if ([
|
|
399
|
+
"Deployment",
|
|
400
|
+
"StatefulSet",
|
|
401
|
+
"ReplicaSet",
|
|
402
|
+
"ReplicationController",
|
|
403
|
+
].includes(resource.type)) {
|
|
404
|
+
const metadata = object(value.metadata);
|
|
405
|
+
const hasAutoscaler = resources.some((r) => {
|
|
406
|
+
const target = object(object(r.value.spec).scaleTargetRef);
|
|
407
|
+
return (r.type === "HorizontalPodAutoscaler" &&
|
|
408
|
+
target.kind === resource.type &&
|
|
409
|
+
target.name === metadata.name &&
|
|
410
|
+
(object(r.value.metadata).namespace ?? "default") ===
|
|
411
|
+
(metadata.namespace ?? "default"));
|
|
412
|
+
});
|
|
413
|
+
check("K8S011", !hasAutoscaler &&
|
|
414
|
+
(spec.replicas === undefined ||
|
|
415
|
+
(typeof spec.replicas === "number" && spec.replicas < 2)), ["spec", "replicas"]);
|
|
416
|
+
}
|
|
417
|
+
for (const group of ["containers", "initContainers", "ephemeralContainers"]) {
|
|
418
|
+
array(podSpec[group]).forEach((raw, index) => {
|
|
419
|
+
const container = object(raw);
|
|
420
|
+
const at = [...prefix, group, index];
|
|
421
|
+
const security = object(container.securityContext);
|
|
422
|
+
check("K8S001", security.privileged === true ||
|
|
423
|
+
security.allowPrivilegeEscalation === true, [...at, "securityContext"]);
|
|
424
|
+
check("K8S004", (security.runAsNonRoot ?? podSecurity.runAsNonRoot) !== true ||
|
|
425
|
+
(security.runAsUser ?? podSecurity.runAsUser) === 0, [...at, "securityContext"]);
|
|
426
|
+
check("K8S005", security.readOnlyRootFilesystem !== true, [
|
|
427
|
+
...at,
|
|
428
|
+
"securityContext",
|
|
429
|
+
]);
|
|
430
|
+
check("K8S006", array(object(security.capabilities).add).some((v) => ["ALL", "SYS_ADMIN", "NET_ADMIN", "SYS_PTRACE"].includes(String(v))), [...at, "securityContext", "capabilities"]);
|
|
431
|
+
const image = typeof container.image === "string" ? container.image : "";
|
|
432
|
+
check("K8S007", image &&
|
|
433
|
+
!image.includes("@sha256:") &&
|
|
434
|
+
(!image.split("/").at(-1)?.includes(":") ||
|
|
435
|
+
image.endsWith(":latest")), [...at, "image"]);
|
|
436
|
+
if (group !== "ephemeralContainers") {
|
|
437
|
+
const bounds = object(container.resources);
|
|
438
|
+
const requests = object(bounds.requests);
|
|
439
|
+
check("K8S008", requests.cpu == null ||
|
|
440
|
+
requests.memory == null ||
|
|
441
|
+
object(bounds.limits).memory == null, [...at, "resources"]);
|
|
442
|
+
}
|
|
443
|
+
if (group === "containers" &&
|
|
444
|
+
!["Job", "CronJob"].includes(resource.type)) {
|
|
445
|
+
check("K8S009", array(container.ports).length > 0 && !container.readinessProbe, [...at, "readinessProbe"]);
|
|
446
|
+
check("K8S010", !container.livenessProbe, [...at, "livenessProbe"]);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type IacFormat } from "./parse.js";
|
|
2
|
+
import type { Finding, FindingSeverity } from "../review.js";
|
|
3
|
+
export declare const severityRank: Record<FindingSeverity, number>;
|
|
4
|
+
export type ScanOptions = {
|
|
5
|
+
format?: IacFormat | "auto";
|
|
6
|
+
focus?: "security" | "reliability" | "cost" | "general";
|
|
7
|
+
minSeverity?: FindingSeverity;
|
|
8
|
+
maxFindings?: number;
|
|
9
|
+
};
|
|
10
|
+
export declare function summarize(findings: Finding[]): {
|
|
11
|
+
critical: number;
|
|
12
|
+
high: number;
|
|
13
|
+
medium: number;
|
|
14
|
+
low: number;
|
|
15
|
+
info: number;
|
|
16
|
+
};
|
|
17
|
+
export declare function filterFindings(findings: Finding[], options: ScanOptions): Finding[];
|
|
18
|
+
export declare function scanIac(content: string, options?: ScanOptions): {
|
|
19
|
+
status: string;
|
|
20
|
+
engineVersion: string;
|
|
21
|
+
format: IacFormat;
|
|
22
|
+
focus: "security" | "reliability" | "cost" | "general";
|
|
23
|
+
summary: string;
|
|
24
|
+
parsedResourceCount: number;
|
|
25
|
+
resources: {
|
|
26
|
+
id: string;
|
|
27
|
+
type: string;
|
|
28
|
+
location: import("./parse.js").Location;
|
|
29
|
+
}[];
|
|
30
|
+
findings: Finding[];
|
|
31
|
+
totals: {
|
|
32
|
+
critical: number;
|
|
33
|
+
high: number;
|
|
34
|
+
medium: number;
|
|
35
|
+
low: number;
|
|
36
|
+
info: number;
|
|
37
|
+
};
|
|
38
|
+
totalFindings: number;
|
|
39
|
+
truncated: boolean;
|
|
40
|
+
rulesEvaluated: string[];
|
|
41
|
+
warnings: string[];
|
|
42
|
+
limitations: string[];
|
|
43
|
+
};
|
package/dist/iac/scan.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { parseIac } from "./parse.js";
|
|
2
|
+
import { resourceFindings } from "./rules.js";
|
|
3
|
+
export const severityRank = {
|
|
4
|
+
info: 0,
|
|
5
|
+
low: 1,
|
|
6
|
+
medium: 2,
|
|
7
|
+
high: 3,
|
|
8
|
+
critical: 4,
|
|
9
|
+
};
|
|
10
|
+
export function summarize(findings) {
|
|
11
|
+
const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
12
|
+
for (const finding of findings)
|
|
13
|
+
bySeverity[finding.severity]++;
|
|
14
|
+
return bySeverity;
|
|
15
|
+
}
|
|
16
|
+
export function filterFindings(findings, options) {
|
|
17
|
+
return findings
|
|
18
|
+
.filter((f) => (!options.focus ||
|
|
19
|
+
options.focus === "general" ||
|
|
20
|
+
f.category === options.focus) &&
|
|
21
|
+
severityRank[f.severity] >= severityRank[options.minSeverity ?? "info"])
|
|
22
|
+
.sort((a, b) => severityRank[b.severity] - severityRank[a.severity] ||
|
|
23
|
+
(a.location?.line ?? 0) - (b.location?.line ?? 0) ||
|
|
24
|
+
(a.ruleId ?? "").localeCompare(b.ruleId ?? ""));
|
|
25
|
+
}
|
|
26
|
+
export function scanIac(content, options = {}) {
|
|
27
|
+
const parsed = parseIac(content, options.format);
|
|
28
|
+
const result = resourceFindings(parsed.resources, parsed.format);
|
|
29
|
+
const findings = filterFindings(result.findings, options);
|
|
30
|
+
const limit = options.maxFindings ?? 100;
|
|
31
|
+
return {
|
|
32
|
+
status: "completed",
|
|
33
|
+
engineVersion: "0.5.0",
|
|
34
|
+
format: parsed.format,
|
|
35
|
+
focus: options.focus ?? "general",
|
|
36
|
+
summary: `Scanned ${parsed.resources.length} resources; ${findings.length} findings match the selected filters.`,
|
|
37
|
+
parsedResourceCount: parsed.resources.length,
|
|
38
|
+
resources: parsed.resources.map((r) => ({
|
|
39
|
+
id: r.id,
|
|
40
|
+
type: r.type,
|
|
41
|
+
location: r.locate(),
|
|
42
|
+
})),
|
|
43
|
+
findings: findings.slice(0, limit),
|
|
44
|
+
totals: summarize(findings),
|
|
45
|
+
totalFindings: findings.length,
|
|
46
|
+
truncated: findings.length > limit,
|
|
47
|
+
rulesEvaluated: [...result.checked].sort(),
|
|
48
|
+
warnings: parsed.warnings,
|
|
49
|
+
limitations: [
|
|
50
|
+
"Static configuration review only. No cloud credentials, deployments, external modules, or runtime state are inspected.",
|
|
51
|
+
"Resource-specific checks cover Kubernetes workloads, RBAC and Secrets, plus AWS networking, IAM, S3, RDS, EC2 and EBS. Other resources receive literal credential and URL checks only.",
|
|
52
|
+
...(parsed.format === "terraform"
|
|
53
|
+
? [
|
|
54
|
+
"HCL locations point to resource declarations; property paths identify the affected setting.",
|
|
55
|
+
]
|
|
56
|
+
: []),
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
}
|
package/dist/review.d.ts
CHANGED
|
@@ -3,7 +3,17 @@ export type Finding = {
|
|
|
3
3
|
severity: FindingSeverity;
|
|
4
4
|
title: string;
|
|
5
5
|
detail: string;
|
|
6
|
+
ruleId?: string;
|
|
7
|
+
category?: "security" | "reliability" | "cost";
|
|
8
|
+
remediation?: string;
|
|
9
|
+
location?: {
|
|
10
|
+
line: number;
|
|
11
|
+
column: number;
|
|
12
|
+
path: string;
|
|
13
|
+
};
|
|
14
|
+
resource?: string;
|
|
6
15
|
};
|
|
7
16
|
export declare function architectureFindings(content: string, focus: string): Finding[];
|
|
17
|
+
export declare function literalCredential(value: string): boolean;
|
|
18
|
+
export declare function externalHttp(value: string): boolean;
|
|
8
19
|
export declare function fetchPublicBotHints(token?: string): Promise<string[]>;
|
|
9
|
-
export declare function countIacResources(content: string): number;
|
package/dist/review.js
CHANGED
|
@@ -16,14 +16,16 @@ export function architectureFindings(content, focus) {
|
|
|
16
16
|
detail: `Checked ${content.length} characters with the ${focusValue} ruleset. Findings are pattern-based and should be validated against the running system.`,
|
|
17
17
|
},
|
|
18
18
|
];
|
|
19
|
-
if (shouldInclude(focusValue, "security") &&
|
|
19
|
+
if (shouldInclude(focusValue, "security") &&
|
|
20
|
+
/0\.0\.0\.0(?:\/0)?/.test(content)) {
|
|
20
21
|
findings.push({
|
|
21
22
|
severity: "high",
|
|
22
23
|
title: "Broad network exposure",
|
|
23
24
|
detail: "Detected a possible wide-open CIDR or bind address. Restrict ingress to approved sources and keep public listeners behind the intended edge control.",
|
|
24
25
|
});
|
|
25
26
|
}
|
|
26
|
-
if (shouldInclude(focusValue, "security") &&
|
|
27
|
+
if (shouldInclude(focusValue, "security") &&
|
|
28
|
+
/AKIA[0-9A-Z]{16}/.test(content)) {
|
|
27
29
|
findings.push({
|
|
28
30
|
severity: "critical",
|
|
29
31
|
title: "Possible AWS access key",
|
|
@@ -39,8 +41,9 @@ export function architectureFindings(content, focus) {
|
|
|
39
41
|
});
|
|
40
42
|
}
|
|
41
43
|
if (shouldInclude(focusValue, "security") &&
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
[
|
|
45
|
+
...content.matchAll(/(?:password|passwd|secret|api[_-]?key|auth[_-]?token)["']?\s*[:=]\s*["']([^"'\n]+)["']/gi),
|
|
46
|
+
].some((match) => literalCredential(match[1]))) {
|
|
44
47
|
findings.push({
|
|
45
48
|
severity: "high",
|
|
46
49
|
title: "Hardcoded credential-like value",
|
|
@@ -64,14 +67,15 @@ export function architectureFindings(content, focus) {
|
|
|
64
67
|
});
|
|
65
68
|
}
|
|
66
69
|
if (shouldInclude(focusValue, "security") &&
|
|
67
|
-
|
|
70
|
+
[...content.matchAll(/\bhttp:\/\/[^\s"'<>]+/gi)].some((match) => externalHttp(match[0]))) {
|
|
68
71
|
findings.push({
|
|
69
72
|
severity: "medium",
|
|
70
73
|
title: "Unencrypted HTTP endpoint",
|
|
71
74
|
detail: "Detected an HTTP URL outside localhost. Use HTTPS for service and dependency traffic, and verify certificate validation is enabled.",
|
|
72
75
|
});
|
|
73
76
|
}
|
|
74
|
-
if ((shouldInclude(focusValue, "security") ||
|
|
77
|
+
if ((shouldInclude(focusValue, "security") ||
|
|
78
|
+
shouldInclude(focusValue, "reliability")) &&
|
|
75
79
|
/(?:^|[\s:=])(?:[\w./-]+:)?latest(?:[\s"']|$)/im.test(content)) {
|
|
76
80
|
findings.push({
|
|
77
81
|
severity: "medium",
|
|
@@ -95,14 +99,74 @@ export function architectureFindings(content, focus) {
|
|
|
95
99
|
detail: "The content enables debug mode. Disable it in production to avoid noisy behavior and accidental disclosure of internal details.",
|
|
96
100
|
});
|
|
97
101
|
}
|
|
98
|
-
if (shouldInclude(focusValue, "cost") &&
|
|
102
|
+
if (shouldInclude(focusValue, "cost") &&
|
|
103
|
+
/(?:instance_type|machine_type|vm_size)\s*[:=]/i.test(content)) {
|
|
99
104
|
findings.push({
|
|
100
105
|
severity: "info",
|
|
101
106
|
title: "Compute sizing needs review",
|
|
102
107
|
detail: "Detected an explicit compute size. Compare the selected size with observed utilization and set a review point for scale-up and scale-down decisions.",
|
|
103
108
|
});
|
|
104
109
|
}
|
|
105
|
-
return findings
|
|
110
|
+
return findings.map((finding, index) => {
|
|
111
|
+
if (index === 0)
|
|
112
|
+
return finding;
|
|
113
|
+
const matchers = {
|
|
114
|
+
"Broad network exposure": /0\.0\.0\.0(?:\/0)?/,
|
|
115
|
+
"Possible AWS access key": /AKIA[0-9A-Z]{16}/,
|
|
116
|
+
"Private key material in source": /-----BEGIN (?:RSA |EC |OPENSSH |DSA |)PRIVATE KEY-----/,
|
|
117
|
+
"Hardcoded credential-like value": /(?:password|passwd|secret|api[_-]?key|auth[_-]?token)["']?\s*[:=]\s*["']([^"'\n]+)["']/gi,
|
|
118
|
+
"Wildcard permission detected": /(?:Action|actions?)\s*[:=][^\n]*["']?\*["']?/i,
|
|
119
|
+
"Elevated container privileges": /(?:privileged|hostNetwork|allowPrivilegeEscalation)\s*:\s*true/i,
|
|
120
|
+
"Unencrypted HTTP endpoint": /\bhttp:\/\/[^\s"'<>]+/gi,
|
|
121
|
+
"Unpinned container image": /\blatest\b/,
|
|
122
|
+
"Public data access pattern": /(?:public-read|publicRead|allUsers)/i,
|
|
123
|
+
"Debug mode enabled": /(?:debug|app_debug)\s*[:=]\s*["']?(?:true|1|yes)/i,
|
|
124
|
+
"Compute sizing needs review": /(?:instance_type|machine_type|vm_size)\s*[:=]/i,
|
|
125
|
+
};
|
|
126
|
+
const pattern = matchers[finding.title];
|
|
127
|
+
const matches = pattern
|
|
128
|
+
? [
|
|
129
|
+
...content.matchAll(new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g")),
|
|
130
|
+
]
|
|
131
|
+
: [];
|
|
132
|
+
const match = matches.find((m) => finding.title === "Hardcoded credential-like value"
|
|
133
|
+
? literalCredential(m[1])
|
|
134
|
+
: finding.title === "Unencrypted HTTP endpoint"
|
|
135
|
+
? externalHttp(m[0])
|
|
136
|
+
: true);
|
|
137
|
+
const offset = match?.index ?? 0;
|
|
138
|
+
const before = content.slice(0, offset);
|
|
139
|
+
const ruleIndex = Object.keys(matchers).indexOf(finding.title) + 1;
|
|
140
|
+
return {
|
|
141
|
+
...finding,
|
|
142
|
+
ruleId: `APP${String(ruleIndex).padStart(3, "0")}`,
|
|
143
|
+
category: (finding.title === "Compute sizing needs review"
|
|
144
|
+
? "cost"
|
|
145
|
+
: ["Debug mode enabled", "Unpinned container image"].includes(finding.title)
|
|
146
|
+
? "reliability"
|
|
147
|
+
: "security"),
|
|
148
|
+
remediation: finding.detail,
|
|
149
|
+
location: {
|
|
150
|
+
line: before.split("\n").length,
|
|
151
|
+
column: offset - before.lastIndexOf("\n"),
|
|
152
|
+
path: "source",
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
export function literalCredential(value) {
|
|
158
|
+
return (value.trim().length > 0 &&
|
|
159
|
+
!/^(?:\$\{|\{\{|<[^>]+>$|process\.env\.|var\.|secrets?\.)/.test(value));
|
|
160
|
+
}
|
|
161
|
+
export function externalHttp(value) {
|
|
162
|
+
try {
|
|
163
|
+
const url = new URL(value);
|
|
164
|
+
return (url.protocol === "http:" &&
|
|
165
|
+
!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname));
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
106
170
|
}
|
|
107
171
|
export async function fetchPublicBotHints(token) {
|
|
108
172
|
try {
|
|
@@ -125,6 +189,3 @@ export async function fetchPublicBotHints(token) {
|
|
|
125
189
|
return [];
|
|
126
190
|
}
|
|
127
191
|
}
|
|
128
|
-
export function countIacResources(content) {
|
|
129
|
-
return (content.match(/\bresource\b|\bkind:\s*\w+/gi) ?? []).length;
|
|
130
|
-
}
|
package/dist/server.js
CHANGED
|
@@ -1,15 +1,60 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getApiAccessToken, requireApiAuth, unauthorizedContent, } from "./auth.js";
|
|
4
|
-
import { architectureFindings,
|
|
4
|
+
import { architectureFindings, fetchPublicBotHints } from "./review.js";
|
|
5
|
+
import { looksLikeIac, ScanInputError } from "./iac/parse.js";
|
|
6
|
+
import { filterFindings, scanIac, summarize } from "./iac/scan.js";
|
|
7
|
+
const contentSchema = z
|
|
8
|
+
.string()
|
|
9
|
+
.min(1)
|
|
10
|
+
.max(500_000)
|
|
11
|
+
.refine((value) => value.trim().length > 0, "Content must not be whitespace");
|
|
12
|
+
const filters = {
|
|
13
|
+
focus: z
|
|
14
|
+
.enum(["security", "reliability", "cost", "general"])
|
|
15
|
+
.default("general"),
|
|
16
|
+
minSeverity: z
|
|
17
|
+
.enum(["info", "low", "medium", "high", "critical"])
|
|
18
|
+
.default("info")
|
|
19
|
+
.describe("Lowest finding severity to return"),
|
|
20
|
+
maxFindings: z
|
|
21
|
+
.number()
|
|
22
|
+
.int()
|
|
23
|
+
.min(1)
|
|
24
|
+
.max(500)
|
|
25
|
+
.default(100)
|
|
26
|
+
.describe("Maximum findings returned; totals include all matching findings"),
|
|
27
|
+
};
|
|
28
|
+
function result(output) {
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
31
|
+
structuredContent: output,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function inputError(error) {
|
|
35
|
+
if (!(error instanceof ScanInputError))
|
|
36
|
+
throw error;
|
|
37
|
+
return {
|
|
38
|
+
isError: true,
|
|
39
|
+
content: [
|
|
40
|
+
{
|
|
41
|
+
type: "text",
|
|
42
|
+
text: JSON.stringify({
|
|
43
|
+
error: "invalid_input",
|
|
44
|
+
message: error.message,
|
|
45
|
+
}),
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
5
50
|
export function createServer(remote = false) {
|
|
6
51
|
const server = new McpServer({
|
|
7
52
|
name: "skaleagents-swarm",
|
|
8
|
-
version: "0.
|
|
53
|
+
version: "0.5.0",
|
|
9
54
|
});
|
|
10
55
|
server.registerTool("review_architecture", {
|
|
11
56
|
title: "Review architecture",
|
|
12
|
-
description: "Review application source or infrastructure
|
|
57
|
+
description: "Review application source or parsed infrastructure for security, reliability, and cost risks. Returns located findings, rule IDs, remediation and coverage limits.",
|
|
13
58
|
annotations: {
|
|
14
59
|
readOnlyHint: true,
|
|
15
60
|
destructiveHint: false,
|
|
@@ -17,16 +62,8 @@ export function createServer(remote = false) {
|
|
|
17
62
|
},
|
|
18
63
|
_meta: { securitySchemes: [{ type: "oauth2", scopes: ["mcp"] }] },
|
|
19
64
|
inputSchema: {
|
|
20
|
-
content:
|
|
21
|
-
|
|
22
|
-
.min(1)
|
|
23
|
-
.max(500_000)
|
|
24
|
-
.describe("Application source or IaC text to review"),
|
|
25
|
-
focus: z
|
|
26
|
-
.enum(["security", "reliability", "cost", "general"])
|
|
27
|
-
.optional()
|
|
28
|
-
.default("general")
|
|
29
|
-
.describe("Review focus: security, reliability, cost, general"),
|
|
65
|
+
content: contentSchema.describe("Application source or IaC text to review"),
|
|
66
|
+
...filters,
|
|
30
67
|
format: z
|
|
31
68
|
.enum([
|
|
32
69
|
"terraform",
|
|
@@ -39,7 +76,7 @@ export function createServer(remote = false) {
|
|
|
39
76
|
.default("auto")
|
|
40
77
|
.describe("Content format: terraform, cloudformation, kubernetes, application, auto"),
|
|
41
78
|
},
|
|
42
|
-
}, async ({ content, focus, format }) => {
|
|
79
|
+
}, async ({ content, focus, format, minSeverity, maxFindings }) => {
|
|
43
80
|
let token;
|
|
44
81
|
if (!remote) {
|
|
45
82
|
const auth = await requireApiAuth();
|
|
@@ -49,59 +86,70 @@ export function createServer(remote = false) {
|
|
|
49
86
|
if (!token)
|
|
50
87
|
return unauthorizedContent({ ok: false, reason: "oauth_failed" });
|
|
51
88
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
.string()
|
|
81
|
-
.optional()
|
|
82
|
-
.describe("terraform, cloudformation, kubernetes, auto"),
|
|
83
|
-
},
|
|
84
|
-
}, async ({ content, format }) => {
|
|
85
|
-
if (!remote) {
|
|
86
|
-
const auth = await requireApiAuth();
|
|
87
|
-
if (!auth.ok)
|
|
88
|
-
return unauthorizedContent(auth);
|
|
89
|
+
try {
|
|
90
|
+
const options = { focus, minSeverity, maxFindings };
|
|
91
|
+
if (format !== "application" &&
|
|
92
|
+
(format !== "auto" || looksLikeIac(content))) {
|
|
93
|
+
return result({
|
|
94
|
+
...scanIac(content, { ...options, format }),
|
|
95
|
+
botHints: await fetchPublicBotHints(token),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const findings = filterFindings(architectureFindings(content, focus).slice(1), options);
|
|
99
|
+
return result({
|
|
100
|
+
status: "completed",
|
|
101
|
+
engineVersion: "0.5.0",
|
|
102
|
+
format: "application",
|
|
103
|
+
focus,
|
|
104
|
+
summary: `Application review completed; ${findings.length} findings match the selected filters.`,
|
|
105
|
+
findings: findings.slice(0, maxFindings),
|
|
106
|
+
totalFindings: findings.length,
|
|
107
|
+
totals: summarize(findings),
|
|
108
|
+
truncated: findings.length > maxFindings,
|
|
109
|
+
limitations: [
|
|
110
|
+
"Application checks are text patterns, not a language-aware or runtime analysis. Findings do not establish that code is safe.",
|
|
111
|
+
],
|
|
112
|
+
botHints: await fetchPublicBotHints(token),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
return inputError(error);
|
|
89
117
|
}
|
|
90
|
-
const formatValue = format === "terraform" ||
|
|
91
|
-
format === "cloudformation" ||
|
|
92
|
-
format === "kubernetes" ||
|
|
93
|
-
format === "auto"
|
|
94
|
-
? format
|
|
95
|
-
: "auto";
|
|
96
|
-
const output = {
|
|
97
|
-
status: "stub",
|
|
98
|
-
message: "Full IaC scanning lands in Phase 2 agent-swarm",
|
|
99
|
-
format: formatValue,
|
|
100
|
-
parsedResourceCount: countIacResources(content),
|
|
101
|
-
};
|
|
102
|
-
return {
|
|
103
|
-
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
104
|
-
};
|
|
105
118
|
});
|
|
119
|
+
for (const name of ["scan_iac", "scan_iac_stub"])
|
|
120
|
+
server.registerTool(name, {
|
|
121
|
+
title: name === "scan_iac"
|
|
122
|
+
? "Scan infrastructure"
|
|
123
|
+
: "Scan infrastructure (compatibility alias)",
|
|
124
|
+
description: (name === "scan_iac_stub"
|
|
125
|
+
? "Compatibility alias for scan_iac; runs the full scanner. "
|
|
126
|
+
: "") +
|
|
127
|
+
"Parse Terraform HCL/JSON, CloudFormation YAML/JSON, or Kubernetes manifests. Check security, reliability and cost rules with resource locations and remediation.",
|
|
128
|
+
annotations: {
|
|
129
|
+
readOnlyHint: true,
|
|
130
|
+
destructiveHint: false,
|
|
131
|
+
openWorldHint: false,
|
|
132
|
+
},
|
|
133
|
+
_meta: { securitySchemes: [{ type: "oauth2", scopes: ["mcp"] }] },
|
|
134
|
+
inputSchema: {
|
|
135
|
+
content: contentSchema.describe("Terraform HCL/JSON, CloudFormation YAML/JSON, or Kubernetes YAML/JSON"),
|
|
136
|
+
format: z
|
|
137
|
+
.enum(["terraform", "cloudformation", "kubernetes", "auto"])
|
|
138
|
+
.default("auto"),
|
|
139
|
+
...filters,
|
|
140
|
+
},
|
|
141
|
+
}, async ({ content, format, focus, minSeverity, maxFindings }) => {
|
|
142
|
+
if (!remote) {
|
|
143
|
+
const auth = await requireApiAuth();
|
|
144
|
+
if (!auth.ok)
|
|
145
|
+
return unauthorizedContent(auth);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
return result(scanIac(content, { format, focus, minSeverity, maxFindings }));
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
return inputError(error);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
106
154
|
return server;
|
|
107
155
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skaleagents/swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"start": "node dist/index.js",
|
|
25
25
|
"dev": "tsx src/index.ts",
|
|
26
26
|
"typecheck": "tsc --noEmit",
|
|
27
|
-
"test": "tsx --test src
|
|
27
|
+
"test": "tsx --test src/*.test.ts src/iac/*.test.ts",
|
|
28
28
|
"smoke": "tsx scripts/smoke-tools.mjs"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
35
|
+
"hcl2-parser": "^1.0.3",
|
|
36
|
+
"yaml": "^2.9.1",
|
|
35
37
|
"zod": "^4.4.3"
|
|
36
38
|
},
|
|
37
39
|
"devDependencies": {
|