@skaleagents/swarm 0.3.1 → 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 +66 -6
- package/dist/http.d.ts +2 -0
- package/dist/http.js +134 -0
- 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/index.js +5 -87
- package/dist/review.d.ts +12 -2
- package/dist/review.js +74 -12
- package/dist/server.d.ts +2 -0
- package/dist/server.js +155 -0
- package/package.json +11 -3
package/README.md
CHANGED
|
@@ -1,15 +1,74 @@
|
|
|
1
1
|
# @skaleagents/swarm
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
SkaleAgents MCP server with local stdio and hosted Streamable HTTP transports.
|
|
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
|
|
|
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
|
+
|
|
54
|
+
## Hosted connection
|
|
55
|
+
|
|
56
|
+
Use `https://skaleagents.com/mcp` in Claude Desktop or ChatGPT's custom connector
|
|
57
|
+
settings. Choose OAuth and leave client ID and secret fields blank. The client
|
|
58
|
+
registers itself, opens Google sign-in, and asks you to approve MCP access.
|
|
59
|
+
See [client setup](https://skaleagents.com/settings) for Cursor, Claude Code,
|
|
60
|
+
Claude Desktop, ChatGPT, and Codex instructions.
|
|
61
|
+
|
|
62
|
+
The web app hosts this endpoint using `handleMcpRequest` from
|
|
63
|
+
`@skaleagents/swarm/http`. It validates each bearer credential with the API's
|
|
64
|
+
`/api/oauth/mcp-token` endpoint before running a tool. Tokens are bound to the
|
|
65
|
+
MCP resource and cannot access unrelated API routes. Credentials are not
|
|
66
|
+
forwarded to the public bot directory.
|
|
67
|
+
|
|
68
|
+
`protectedResourceMetadata` exports the discovery response for
|
|
69
|
+
`/.well-known/oauth-protected-resource/mcp`.
|
|
70
|
+
|
|
71
|
+
## Local stdio prerequisites
|
|
13
72
|
|
|
14
73
|
1. Node.js 20 or newer. The client connects to `https://api.skaleagents.com` by default.
|
|
15
74
|
2. A browser that can open the SkaleAgents sign-in page.
|
|
@@ -74,13 +133,13 @@ Dev without build:
|
|
|
74
133
|
"mcpServers": {
|
|
75
134
|
"skaleagents": {
|
|
76
135
|
"command": "npx",
|
|
77
|
-
|
|
136
|
+
"args": ["-y", "@skaleagents/swarm@0.5.0"]
|
|
78
137
|
}
|
|
79
138
|
}
|
|
80
139
|
}
|
|
81
140
|
```
|
|
82
141
|
|
|
83
|
-
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`.
|
|
84
143
|
|
|
85
144
|
## Claude Code
|
|
86
145
|
|
|
@@ -94,6 +153,7 @@ Use the published package configuration above in `.mcp.json`. OAuth starts on th
|
|
|
94
153
|
| `PLATFORM_API_URL` | No | Defaults to `https://api.skaleagents.com`. Override only for local development or another API deployment. Empty values use the default. |
|
|
95
154
|
| `SKALEAGENTS_OAUTH_CACHE` | No | OAuth cache path. Default `~/.config/skaleagents/oauth.json`. |
|
|
96
155
|
| `SKALEAGENTS_OAUTH_ENABLED` | No | Set to `false` only to disable browser OAuth. |
|
|
156
|
+
| `MCP_RESOURCE_URL` | No | Hosted transport audience. Defaults to `https://skaleagents.com/mcp`; must match the API OAuth configuration. |
|
|
97
157
|
|
|
98
158
|
## Auth behavior
|
|
99
159
|
|
package/dist/http.d.ts
ADDED
package/dist/http.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
2
|
+
import { createServer } from "./server.js";
|
|
3
|
+
import { getApiUrl } from "./config.js";
|
|
4
|
+
const resource = process.env.MCP_RESOURCE_URL ?? "https://skaleagents.com/mcp";
|
|
5
|
+
const metadataUrl = `${new URL(resource).origin}/.well-known/oauth-protected-resource/mcp`;
|
|
6
|
+
const allowedOrigins = new Set([
|
|
7
|
+
"https://claude.ai",
|
|
8
|
+
"https://chatgpt.com",
|
|
9
|
+
"https://platform.openai.com",
|
|
10
|
+
new URL(resource).origin,
|
|
11
|
+
]);
|
|
12
|
+
const headers = {
|
|
13
|
+
"Cache-Control": "no-store",
|
|
14
|
+
"Access-Control-Allow-Origin": "*",
|
|
15
|
+
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
16
|
+
"Access-Control-Allow-Headers": "Authorization, Content-Type, Accept, MCP-Protocol-Version, MCP-Session-Id, Last-Event-ID",
|
|
17
|
+
"Access-Control-Expose-Headers": "WWW-Authenticate, MCP-Session-Id, MCP-Protocol-Version",
|
|
18
|
+
};
|
|
19
|
+
function json(status, body, extraHeaders = {}) {
|
|
20
|
+
return Response.json(body, {
|
|
21
|
+
status,
|
|
22
|
+
headers: { ...headers, ...extraHeaders },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function challenge() {
|
|
26
|
+
return json(401, { error: "unauthorized" }, {
|
|
27
|
+
"WWW-Authenticate": `Bearer resource_metadata="${metadataUrl}", scope="mcp"`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export function protectedResourceMetadata() {
|
|
31
|
+
return json(200, {
|
|
32
|
+
resource,
|
|
33
|
+
authorization_servers: [getApiUrl()],
|
|
34
|
+
scopes_supported: ["mcp"],
|
|
35
|
+
bearer_methods_supported: ["header"],
|
|
36
|
+
resource_documentation: "https://skaleagents.com/settings",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export async function handleMcpRequest(request) {
|
|
40
|
+
const origin = request.headers.get("origin");
|
|
41
|
+
if (origin && !allowedOrigins.has(origin))
|
|
42
|
+
return json(403, { error: "origin_not_allowed" });
|
|
43
|
+
if (request.method === "OPTIONS")
|
|
44
|
+
return new Response(null, { status: 204, headers });
|
|
45
|
+
const authorization = request.headers.get("authorization");
|
|
46
|
+
if (!authorization || !/^Bearer [^\s]+$/i.test(authorization))
|
|
47
|
+
return challenge();
|
|
48
|
+
try {
|
|
49
|
+
// Validate the opaque token with its issuer, including the MCP audience.
|
|
50
|
+
const validation = await fetch(`${getApiUrl()}/api/oauth/mcp-token`, {
|
|
51
|
+
headers: { Authorization: authorization, Accept: "application/json" },
|
|
52
|
+
signal: AbortSignal.timeout(10_000),
|
|
53
|
+
redirect: "error",
|
|
54
|
+
cache: "no-store",
|
|
55
|
+
});
|
|
56
|
+
if (validation.status === 401 || validation.status === 403)
|
|
57
|
+
return challenge();
|
|
58
|
+
if (!validation.ok)
|
|
59
|
+
return json(503, { error: "authorization_unavailable" });
|
|
60
|
+
const token = (await validation.json());
|
|
61
|
+
if (!token.active ||
|
|
62
|
+
token.resource !== resource ||
|
|
63
|
+
token.scope !== "mcp" ||
|
|
64
|
+
!token.expiresAt ||
|
|
65
|
+
token.expiresAt * 1000 <= Date.now()) {
|
|
66
|
+
return challenge();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return json(503, { error: "authorization_unavailable" });
|
|
71
|
+
}
|
|
72
|
+
if (request.method !== "POST")
|
|
73
|
+
return json(405, { error: "method_not_allowed" }, { Allow: "POST, OPTIONS" });
|
|
74
|
+
if (!request.headers.get("content-type")?.startsWith("application/json"))
|
|
75
|
+
return json(415, { error: "expected_json" });
|
|
76
|
+
let body;
|
|
77
|
+
try {
|
|
78
|
+
const reader = request.body?.getReader();
|
|
79
|
+
if (!reader)
|
|
80
|
+
return json(400, { error: "missing_body" });
|
|
81
|
+
const chunks = [];
|
|
82
|
+
let size = 0;
|
|
83
|
+
try {
|
|
84
|
+
for (;;) {
|
|
85
|
+
const { value, done } = await reader.read();
|
|
86
|
+
if (done)
|
|
87
|
+
break;
|
|
88
|
+
size += value.byteLength;
|
|
89
|
+
if (size > 2_100_000) {
|
|
90
|
+
await reader.cancel();
|
|
91
|
+
return json(413, { error: "request_too_large" });
|
|
92
|
+
}
|
|
93
|
+
chunks.push(value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
reader.releaseLock();
|
|
98
|
+
}
|
|
99
|
+
body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return json(400, {
|
|
103
|
+
jsonrpc: "2.0",
|
|
104
|
+
id: null,
|
|
105
|
+
error: { code: -32700, message: "Invalid JSON" },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const server = createServer(true);
|
|
109
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
110
|
+
sessionIdGenerator: undefined,
|
|
111
|
+
enableJsonResponse: true,
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
await server.connect(transport);
|
|
115
|
+
const response = await transport.handleRequest(request, {
|
|
116
|
+
parsedBody: body,
|
|
117
|
+
});
|
|
118
|
+
// Finish the JSON response before closing this request-scoped transport.
|
|
119
|
+
const responseBody = await response.arrayBuffer();
|
|
120
|
+
const responseHeaders = new Headers(response.headers);
|
|
121
|
+
for (const [key, value] of Object.entries(headers))
|
|
122
|
+
responseHeaders.set(key, value);
|
|
123
|
+
return new Response(responseBody.byteLength ? responseBody : null, {
|
|
124
|
+
status: response.status,
|
|
125
|
+
headers: responseHeaders,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return json(500, { error: "mcp_request_failed" });
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
await server.close();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -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 {};
|