@raviraj87/armorcode-mcp 1.1.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/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/client.js +449 -0
- package/dist/config.js +23 -0
- package/dist/findingDetail.js +58 -0
- package/dist/findingFilters.js +65 -0
- package/dist/index.js +15 -0
- package/dist/resolve.js +45 -0
- package/dist/tools/common.js +18 -0
- package/dist/tools/registerTools.js +360 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +15 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ravi Raj
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# ArmorCode MCP
|
|
2
|
+
|
|
3
|
+
Local MCP server for **ArmorCode** — finding triage, scans, exceptions, risk scores, and release gate checks.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"mcpServers": {
|
|
12
|
+
"armorcode": {
|
|
13
|
+
"command": "npx",
|
|
14
|
+
"args": ["-y", "@raviraj87/armorcode-mcp"],
|
|
15
|
+
"env": {
|
|
16
|
+
"ARMORCODE_URL": "https://your-tenant.armorcode.com",
|
|
17
|
+
"ARMORCODE_API_TOKEN": "your-api-token"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`ARMORCODE_WRITE_ENABLED` is **optional** — omit it for safe defaults (write tools preview only).
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Environment variables
|
|
29
|
+
|
|
30
|
+
| Variable | Required | Description |
|
|
31
|
+
|----------|----------|-------------|
|
|
32
|
+
| `ARMORCODE_URL` | Yes | Tenant base URL |
|
|
33
|
+
| `ARMORCODE_API_TOKEN` | Yes | API token from Manage → Integrations → API |
|
|
34
|
+
| `ARMORCODE_WRITE_ENABLED` | No | Set `true` to apply writes with `dryRun: false` |
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Tools (v1.0)
|
|
39
|
+
|
|
40
|
+
### Findings
|
|
41
|
+
| Tool | Purpose |
|
|
42
|
+
|------|---------|
|
|
43
|
+
| `armorcode_search_findings` | Search/filter findings |
|
|
44
|
+
| `armorcode_get_finding` | Full finding detail |
|
|
45
|
+
| `armorcode_get_finding_events` | Audit trail |
|
|
46
|
+
| `armorcode_get_findings_stats` | Counts by severity/status |
|
|
47
|
+
| `armorcode_get_severity_stats` | Severity breakdown |
|
|
48
|
+
|
|
49
|
+
### Status & comments (write, dry-run default)
|
|
50
|
+
| Tool | Purpose |
|
|
51
|
+
|------|---------|
|
|
52
|
+
| `armorcode_confirm_finding` | OPEN → CONFIRMED |
|
|
53
|
+
| `armorcode_control_finding` | → CONTROLLED |
|
|
54
|
+
| `armorcode_block_finding` | Block via accept-risk |
|
|
55
|
+
| `armorcode_accept_risk` | Accept risk |
|
|
56
|
+
| `armorcode_mark_false_positive` | False positive |
|
|
57
|
+
| `armorcode_reopen_finding` | Reopen |
|
|
58
|
+
| `armorcode_triage_finding` | → TRIAGE |
|
|
59
|
+
| `armorcode_mitigate_finding` | → MITIGATED |
|
|
60
|
+
| `armorcode_suppress_finding` | Suppress |
|
|
61
|
+
| `armorcode_add_comment` | Add note |
|
|
62
|
+
| `armorcode_add_exploitability_comment` | Structured exploitability |
|
|
63
|
+
| `armorcode_update_finding_severity` | Change severity |
|
|
64
|
+
|
|
65
|
+
### Products & risk
|
|
66
|
+
| Tool | Purpose |
|
|
67
|
+
|------|---------|
|
|
68
|
+
| `armorcode_list_products` | List products/groups |
|
|
69
|
+
| `armorcode_list_subproducts` | List subproducts |
|
|
70
|
+
| `armorcode_get_product_risk_score` | Product risk |
|
|
71
|
+
| `armorcode_get_subproduct_risk_score` | Subproduct risk |
|
|
72
|
+
| `armorcode_get_all_product_risk_scores` | All products |
|
|
73
|
+
|
|
74
|
+
### Scans & exceptions
|
|
75
|
+
| Tool | Purpose |
|
|
76
|
+
|------|---------|
|
|
77
|
+
| `armorcode_list_scans` | List scans |
|
|
78
|
+
| `armorcode_get_scan` | Scan details |
|
|
79
|
+
| `armorcode_execute_scan` | Trigger scan |
|
|
80
|
+
| `armorcode_trigger_scan_pull` | PULL tool scheduler (Black Duck, etc.) |
|
|
81
|
+
| `armorcode_list_scan_schedulers` | List schedulers |
|
|
82
|
+
| `armorcode_delete_scan_scheduler` | Clean up scheduler |
|
|
83
|
+
| `armorcode_list_exceptions` | Open exceptions |
|
|
84
|
+
| `armorcode_get_exception` | Exception detail |
|
|
85
|
+
| `armorcode_create_exception` | Create risk register entry |
|
|
86
|
+
| `armorcode_release_gate_status` | Jenkins release gate check |
|
|
87
|
+
|
|
88
|
+
### Escape hatch
|
|
89
|
+
| Tool | Purpose |
|
|
90
|
+
|------|---------|
|
|
91
|
+
| `armorcode_health` | Connectivity check |
|
|
92
|
+
| `armorcode_api` | Any GET/POST/PUT/DELETE path |
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Dev
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
cd armorcode-mcp
|
|
100
|
+
npm install
|
|
101
|
+
npm run build
|
|
102
|
+
ARMORCODE_URL=... ARMORCODE_API_TOKEN=... npm run test:readonly
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT — Copyright © 2026 Ravi Raj
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import fetch from "node-fetch";
|
|
2
|
+
import { computeStats, matchesFindingFilters, normalizeStatusList, productExploitabilityTag, } from "./findingFilters.js";
|
|
3
|
+
export class ArmorCodeClient {
|
|
4
|
+
config;
|
|
5
|
+
constructor(config) {
|
|
6
|
+
this.config = config;
|
|
7
|
+
}
|
|
8
|
+
get writeEnabled() {
|
|
9
|
+
return this.config.writeEnabled;
|
|
10
|
+
}
|
|
11
|
+
async get(path, query) {
|
|
12
|
+
const url = new URL(this.path(path), this.config.url);
|
|
13
|
+
this.applyQuery(url, query);
|
|
14
|
+
return this.request("GET", url.toString());
|
|
15
|
+
}
|
|
16
|
+
async post(path, body, query) {
|
|
17
|
+
const url = new URL(this.path(path), this.config.url);
|
|
18
|
+
this.applyQuery(url, query);
|
|
19
|
+
return this.request("POST", url.toString(), body);
|
|
20
|
+
}
|
|
21
|
+
async put(path, body) {
|
|
22
|
+
return this.request("PUT", `${this.config.url}${this.path(path)}`, body);
|
|
23
|
+
}
|
|
24
|
+
async delete(path) {
|
|
25
|
+
return this.request("DELETE", `${this.config.url}${this.path(path)}`);
|
|
26
|
+
}
|
|
27
|
+
// --- Findings ---
|
|
28
|
+
async searchFindings(opts = {}) {
|
|
29
|
+
const filters = {};
|
|
30
|
+
if (opts.productIds?.length)
|
|
31
|
+
filters.product = opts.productIds;
|
|
32
|
+
if (opts.subproductIds?.length)
|
|
33
|
+
filters.subProduct = opts.subproductIds;
|
|
34
|
+
// Severity server-side filter is unreliable on many tenants — applied client-side below.
|
|
35
|
+
if (opts.status?.length)
|
|
36
|
+
filters.status = normalizeStatusList(opts.status);
|
|
37
|
+
if (opts.sourceTools?.length)
|
|
38
|
+
filters.toolSource = opts.sourceTools;
|
|
39
|
+
if (opts.cves?.length)
|
|
40
|
+
filters.cveId = opts.cves;
|
|
41
|
+
if (opts.tags?.length)
|
|
42
|
+
filters.tags = opts.tags;
|
|
43
|
+
const url = new URL("/api/findings", this.config.url);
|
|
44
|
+
url.searchParams.set("size", String(opts.size ?? 50));
|
|
45
|
+
if (opts.afterKey != null)
|
|
46
|
+
url.searchParams.set("afterKey", String(opts.afterKey));
|
|
47
|
+
const raw = await this.request("POST", url.toString(), { filters });
|
|
48
|
+
const clientFilter = opts.severity?.length || opts.excludeTagPrefix || opts.findingAgeDays != null
|
|
49
|
+
? {
|
|
50
|
+
severity: opts.severity,
|
|
51
|
+
excludeTagPrefix: opts.excludeTagPrefix,
|
|
52
|
+
findingAgeDays: opts.findingAgeDays,
|
|
53
|
+
}
|
|
54
|
+
: undefined;
|
|
55
|
+
if (!clientFilter)
|
|
56
|
+
return raw;
|
|
57
|
+
const data = raw?.data;
|
|
58
|
+
const findings = Array.isArray(data?.findings) ? data.findings : [];
|
|
59
|
+
const filtered = findings.filter((f) => matchesFindingFilters(f, clientFilter));
|
|
60
|
+
return {
|
|
61
|
+
...raw,
|
|
62
|
+
data: { ...data, findings: filtered, clientFiltered: true },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
async searchFindingsAll(opts = {}) {
|
|
66
|
+
const maxPages = opts.maxPages ?? 20;
|
|
67
|
+
const clientFilter = opts.severity?.length || opts.excludeTagPrefix || opts.findingAgeDays != null
|
|
68
|
+
? {
|
|
69
|
+
severity: opts.severity,
|
|
70
|
+
excludeTagPrefix: opts.excludeTagPrefix,
|
|
71
|
+
findingAgeDays: opts.findingAgeDays,
|
|
72
|
+
}
|
|
73
|
+
: undefined;
|
|
74
|
+
const apiOpts = { ...opts };
|
|
75
|
+
delete apiOpts.maxPages;
|
|
76
|
+
if (clientFilter) {
|
|
77
|
+
apiOpts.severity = undefined;
|
|
78
|
+
apiOpts.excludeTagPrefix = undefined;
|
|
79
|
+
apiOpts.findingAgeDays = undefined;
|
|
80
|
+
}
|
|
81
|
+
const all = [];
|
|
82
|
+
let afterKey = opts.afterKey;
|
|
83
|
+
let pagesFetched = 0;
|
|
84
|
+
for (let page = 0; page < maxPages; page++) {
|
|
85
|
+
const raw = await this.searchFindings({ ...apiOpts, afterKey, size: opts.size ?? 100 });
|
|
86
|
+
const data = raw?.data;
|
|
87
|
+
const batch = Array.isArray(data?.findings) ? data.findings : [];
|
|
88
|
+
pagesFetched += 1;
|
|
89
|
+
if (!batch.length)
|
|
90
|
+
break;
|
|
91
|
+
all.push(...batch);
|
|
92
|
+
const next = data?.afterKey;
|
|
93
|
+
if (next == null || next === -1)
|
|
94
|
+
break;
|
|
95
|
+
afterKey = Number(next);
|
|
96
|
+
}
|
|
97
|
+
const findings = clientFilter ? all.filter((f) => matchesFindingFilters(f, clientFilter)) : all;
|
|
98
|
+
return { findings, pagesFetched, totalFetched: all.length, afterKey: afterKey ?? null };
|
|
99
|
+
}
|
|
100
|
+
async getFinding(findingId) {
|
|
101
|
+
return this.get(`/user/findings/${findingId}`);
|
|
102
|
+
}
|
|
103
|
+
async getFindingEvents(findingId) {
|
|
104
|
+
return this.get(`/user/findings/${findingId}/events`);
|
|
105
|
+
}
|
|
106
|
+
statusBody(reason = "", notes = "") {
|
|
107
|
+
const body = {};
|
|
108
|
+
if (reason)
|
|
109
|
+
body.reason = reason;
|
|
110
|
+
if (notes)
|
|
111
|
+
body.notes = notes;
|
|
112
|
+
return body;
|
|
113
|
+
}
|
|
114
|
+
async confirmFinding(findingId, reason = "", notes = "") {
|
|
115
|
+
return this.put(`/user/findings/confirm/${findingId}`, this.statusBody(reason, notes));
|
|
116
|
+
}
|
|
117
|
+
async controlFinding(findingId, reason = "", notes = "") {
|
|
118
|
+
return this.put(`/user/findings/controlled/${findingId}`, this.statusBody(reason, notes));
|
|
119
|
+
}
|
|
120
|
+
async acceptRiskFinding(findingId, reason = "", notes = "") {
|
|
121
|
+
return this.put(`/user/findings/accept-risk/${findingId}`, this.statusBody(reason, notes));
|
|
122
|
+
}
|
|
123
|
+
async openFinding(findingId, reason = "", notes = "") {
|
|
124
|
+
return this.put(`/user/findings/reopen/${findingId}`, this.statusBody(reason, notes));
|
|
125
|
+
}
|
|
126
|
+
async falsePositiveFinding(findingId, reason = "", notes = "") {
|
|
127
|
+
return this.put(`/user/findings/false-positive/${findingId}`, this.statusBody(reason, notes));
|
|
128
|
+
}
|
|
129
|
+
async triageFinding(findingId, reason = "", notes = "") {
|
|
130
|
+
return this.put(`/user/findings/triage/${findingId}`, this.statusBody(reason, notes));
|
|
131
|
+
}
|
|
132
|
+
async inProgressFinding(findingId, reason = "", notes = "") {
|
|
133
|
+
return this.put(`/user/findings/inProgress/${findingId}`, this.statusBody(reason, notes));
|
|
134
|
+
}
|
|
135
|
+
async suppressFinding(findingId, reason = "", notes = "") {
|
|
136
|
+
return this.put(`/user/findings/suppressed/${findingId}`, this.statusBody(reason, notes));
|
|
137
|
+
}
|
|
138
|
+
async mitigateFinding(findingId, reason = "", notes = "") {
|
|
139
|
+
return this.put(`/user/findings/mitigated/${findingId}`, this.statusBody(reason, notes));
|
|
140
|
+
}
|
|
141
|
+
async bulkConfirm(findingIds, reason = "", notes = "") {
|
|
142
|
+
return this.put("/user/findings/bulk/confirm", { ...this.statusBody(reason, notes), findingIds });
|
|
143
|
+
}
|
|
144
|
+
async bulkReopen(findingIds, reason = "", notes = "") {
|
|
145
|
+
return this.put("/user/findings/bulk/reopen", { ...this.statusBody(reason, notes), findingIds });
|
|
146
|
+
}
|
|
147
|
+
async bulkAcceptRisk(findingIds, reason = "", notes = "") {
|
|
148
|
+
return this.put("/user/findings/bulk/accept-risk", { ...this.statusBody(reason, notes), findingIds });
|
|
149
|
+
}
|
|
150
|
+
async bulkFalsePositive(findingIds, reason = "", notes = "") {
|
|
151
|
+
return this.put("/user/findings/bulk/false-positive", { ...this.statusBody(reason, notes), findingIds });
|
|
152
|
+
}
|
|
153
|
+
async addComment(findingId, note) {
|
|
154
|
+
return this.post(`/user/findings/${findingId}/comment`, {
|
|
155
|
+
note,
|
|
156
|
+
markdownText: note,
|
|
157
|
+
isManualComment: true,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
async updateFindingTags(findingId, opts) {
|
|
161
|
+
const findingTags = this.buildFindingTagsPayload(opts);
|
|
162
|
+
return this.put("/user/findings/findingTags", {
|
|
163
|
+
findingTags,
|
|
164
|
+
findingIds: [findingId],
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
async bulkUpdateFindingTags(findingIds, opts) {
|
|
168
|
+
const findingTags = this.buildFindingTagsPayload(opts);
|
|
169
|
+
const results = [];
|
|
170
|
+
for (const findingId of findingIds) {
|
|
171
|
+
results.push(await this.put("/user/findings/findingTags", { findingTags, findingIds: [findingId] }));
|
|
172
|
+
}
|
|
173
|
+
return { findingIds, results };
|
|
174
|
+
}
|
|
175
|
+
/** ArmorCode PUT /user/findings/findingTags expects tags to set, not the full tag list. */
|
|
176
|
+
buildFindingTagsPayload(opts) {
|
|
177
|
+
const tags = [...(opts.added ?? [])];
|
|
178
|
+
for (const raw of opts.removed ?? []) {
|
|
179
|
+
const key = raw.includes(":") ? raw.slice(0, raw.indexOf(":")) : raw;
|
|
180
|
+
const clear = key ? `${key}:` : raw;
|
|
181
|
+
if (!tags.some((t) => t === raw || t.startsWith(clear)))
|
|
182
|
+
tags.push(clear);
|
|
183
|
+
}
|
|
184
|
+
return tags;
|
|
185
|
+
}
|
|
186
|
+
/** Comment first, then tag — ArmorCode triage order. */
|
|
187
|
+
async setProductExploitability(findingId, exploitable, reason) {
|
|
188
|
+
const label = exploitable ? "exploitable" : "notexploitable";
|
|
189
|
+
const comment = `${label}\nreason: ${reason}`;
|
|
190
|
+
const commentResult = await this.addComment(findingId, comment);
|
|
191
|
+
const tag = productExploitabilityTag(exploitable);
|
|
192
|
+
const tags = await this.updateFindingTags(findingId, { added: [tag] });
|
|
193
|
+
return { comment: commentResult, tags };
|
|
194
|
+
}
|
|
195
|
+
async updateSeverity(findingId, severity, reason = "", notes = "") {
|
|
196
|
+
const body = { severity: severity.toUpperCase() };
|
|
197
|
+
if (reason)
|
|
198
|
+
body.reason = reason;
|
|
199
|
+
if (notes)
|
|
200
|
+
body.notes = notes;
|
|
201
|
+
return this.put(`/user/findings/severity/${findingId}`, body);
|
|
202
|
+
}
|
|
203
|
+
async bulkUpdateSeverity(findingIds, severity, reason = "", notes = "") {
|
|
204
|
+
const body = { findingIds, approved: true, severity: severity.toUpperCase() };
|
|
205
|
+
if (reason)
|
|
206
|
+
body.reason = reason;
|
|
207
|
+
if (notes)
|
|
208
|
+
body.notes = notes;
|
|
209
|
+
return this.put("/api/approve-reject/severity", body);
|
|
210
|
+
}
|
|
211
|
+
async assignFindingToException(findingId, reason = "", notes = "") {
|
|
212
|
+
return this.put(`/user/findings/update-risk-register/${findingId}`, this.statusBody(reason, notes));
|
|
213
|
+
}
|
|
214
|
+
// --- Stats ---
|
|
215
|
+
async getFindingStats(opts) {
|
|
216
|
+
try {
|
|
217
|
+
const filters = {};
|
|
218
|
+
if (opts.productIds?.length)
|
|
219
|
+
filters.product = opts.productIds;
|
|
220
|
+
if (opts.subproductIds?.length)
|
|
221
|
+
filters.subProduct = opts.subproductIds;
|
|
222
|
+
if (opts.status?.length)
|
|
223
|
+
filters.status = normalizeStatusList(opts.status);
|
|
224
|
+
if (opts.sourceTools?.length)
|
|
225
|
+
filters.source = opts.sourceTools;
|
|
226
|
+
return await this.post("/api/businessunits/dashboard/findingStats", { filters, aggField: "severity" });
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
const { findings } = await this.searchFindingsAll({
|
|
230
|
+
productIds: opts.productIds,
|
|
231
|
+
subproductIds: opts.subproductIds,
|
|
232
|
+
severity: opts.severity,
|
|
233
|
+
status: opts.status,
|
|
234
|
+
sourceTools: opts.sourceTools,
|
|
235
|
+
maxPages: 20,
|
|
236
|
+
size: 100,
|
|
237
|
+
});
|
|
238
|
+
return {
|
|
239
|
+
...computeStats(findings),
|
|
240
|
+
fallback: true,
|
|
241
|
+
dashboardError: err instanceof Error ? err.message : String(err),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async getSeverityStats(productIds, subproductIds) {
|
|
246
|
+
try {
|
|
247
|
+
const filters = {};
|
|
248
|
+
if (productIds?.length)
|
|
249
|
+
filters.product = productIds;
|
|
250
|
+
if (subproductIds?.length)
|
|
251
|
+
filters.subProduct = subproductIds;
|
|
252
|
+
return await this.post("/api/businessunits/dashboard/findingStats/severity-stats", { filters });
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
const { findings } = await this.searchFindingsAll({
|
|
256
|
+
productIds,
|
|
257
|
+
subproductIds,
|
|
258
|
+
maxPages: 20,
|
|
259
|
+
size: 100,
|
|
260
|
+
});
|
|
261
|
+
return {
|
|
262
|
+
...computeStats(findings),
|
|
263
|
+
fallback: true,
|
|
264
|
+
dashboardError: err instanceof Error ? err.message : String(err),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// --- Products ---
|
|
269
|
+
async listProducts(search = "", pageSize = 100) {
|
|
270
|
+
const body = { pageSize, pageNumber: 0, calculateRisk: true };
|
|
271
|
+
if (search)
|
|
272
|
+
body.search = search;
|
|
273
|
+
return this.post("/api/product/short", body);
|
|
274
|
+
}
|
|
275
|
+
async listSubproducts(opts = {}) {
|
|
276
|
+
const body = {};
|
|
277
|
+
if (opts.productIds?.length)
|
|
278
|
+
body.productIds = opts.productIds;
|
|
279
|
+
if (opts.search)
|
|
280
|
+
body.search = opts.search;
|
|
281
|
+
return this.post("/api/sub-product/name-id", body);
|
|
282
|
+
}
|
|
283
|
+
async createSubproduct(productId, name, description = "") {
|
|
284
|
+
const body = { name, product: { id: productId } };
|
|
285
|
+
if (description)
|
|
286
|
+
body.description = description;
|
|
287
|
+
return this.post("/api/sub-product", body);
|
|
288
|
+
}
|
|
289
|
+
// --- Risk ---
|
|
290
|
+
async getAllProductRiskScores(environmentName = "") {
|
|
291
|
+
return this.get("/compliance/product-risks", environmentName ? { environmentName } : undefined);
|
|
292
|
+
}
|
|
293
|
+
async getProductRiskScore(productId, environmentName = "") {
|
|
294
|
+
return this.get(`/compliance/product-risk/${productId}`, environmentName ? { environmentName } : undefined);
|
|
295
|
+
}
|
|
296
|
+
async getSubproductRiskScore(subproductId, environmentName = "") {
|
|
297
|
+
return this.get(`/compliance/subproduct-risks/${subproductId}`, environmentName ? { environmentName } : undefined);
|
|
298
|
+
}
|
|
299
|
+
// --- Environments ---
|
|
300
|
+
async listEnvironments(subproductId) {
|
|
301
|
+
return this.get("/user/environment/elastic/short", { subProductId: subproductId });
|
|
302
|
+
}
|
|
303
|
+
// --- Scans ---
|
|
304
|
+
async listScans(opts = {}) {
|
|
305
|
+
return this.get("/v2/scans", {
|
|
306
|
+
page: opts.page ?? 0,
|
|
307
|
+
size: opts.size ?? 20,
|
|
308
|
+
sort: opts.sort ?? "createdAt,desc",
|
|
309
|
+
productIds: opts.productIds,
|
|
310
|
+
subProductIds: opts.subproductIds,
|
|
311
|
+
environmentNames: opts.environmentNames,
|
|
312
|
+
toolNames: opts.toolNames,
|
|
313
|
+
scanStatuses: opts.scanStatuses?.map((s) => s.toUpperCase()),
|
|
314
|
+
scanTypes: opts.scanTypes,
|
|
315
|
+
triggerBy: opts.triggerBy,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
async getScan(scanId) {
|
|
319
|
+
return this.get(`/api/scans/${scanId}`);
|
|
320
|
+
}
|
|
321
|
+
async executeScan(subproductId, toolName, environmentName) {
|
|
322
|
+
const url = new URL(`/api/sub-product/${subproductId}/execute-scan`, this.config.url);
|
|
323
|
+
url.searchParams.set("toolName", toolName);
|
|
324
|
+
url.searchParams.set("environmentName", environmentName);
|
|
325
|
+
return this.request("POST", url.toString());
|
|
326
|
+
}
|
|
327
|
+
async executeScanForFinding(findingId) {
|
|
328
|
+
return this.post(`/user/findings/${findingId}/execute-scan`);
|
|
329
|
+
}
|
|
330
|
+
// --- Exceptions ---
|
|
331
|
+
async listOpenExceptions() {
|
|
332
|
+
return this.get("/api/risk-register/open");
|
|
333
|
+
}
|
|
334
|
+
async getException(exceptionId) {
|
|
335
|
+
return this.get(`/api/risk-register/${exceptionId}`);
|
|
336
|
+
}
|
|
337
|
+
async createException(opts) {
|
|
338
|
+
const scope = { productId: opts.productId };
|
|
339
|
+
if (opts.subproductIds?.length)
|
|
340
|
+
scope.subProductIds = opts.subproductIds;
|
|
341
|
+
if (opts.environmentName)
|
|
342
|
+
scope.environmentName = opts.environmentName;
|
|
343
|
+
if (opts.autoAddFindings)
|
|
344
|
+
scope.autoAddInScopeFinding = true;
|
|
345
|
+
const body = {
|
|
346
|
+
name: opts.name,
|
|
347
|
+
description: opts.description,
|
|
348
|
+
startDate: opts.startDate,
|
|
349
|
+
endDate: opts.endDate,
|
|
350
|
+
scope,
|
|
351
|
+
};
|
|
352
|
+
if (opts.reasons?.length)
|
|
353
|
+
body.reasons = opts.reasons;
|
|
354
|
+
return this.post("/api/risk-register", body);
|
|
355
|
+
}
|
|
356
|
+
// --- Schedulers ---
|
|
357
|
+
async listToolSchedulers(toolName) {
|
|
358
|
+
return this.get(`/user/tools/scheduler/${encodeURIComponent(toolName)}`);
|
|
359
|
+
}
|
|
360
|
+
async getDefaultToolScheduler(toolName) {
|
|
361
|
+
return this.get(`/user/tools/scheduler/default/${encodeURIComponent(toolName)}`);
|
|
362
|
+
}
|
|
363
|
+
async createToolScheduler(opts) {
|
|
364
|
+
const body = {
|
|
365
|
+
toolName: opts.toolName,
|
|
366
|
+
timezone: opts.timezone ?? "UTC",
|
|
367
|
+
interval: Math.max(opts.interval ?? 900000, 900000),
|
|
368
|
+
frequencyUnit: opts.frequencyUnit ?? "MINUTES",
|
|
369
|
+
markInactiveAfter: 1,
|
|
370
|
+
enabled: opts.enabled ?? true,
|
|
371
|
+
};
|
|
372
|
+
if (opts.productId)
|
|
373
|
+
body.product = opts.productId;
|
|
374
|
+
if (opts.subproductId)
|
|
375
|
+
body.subProduct = opts.subproductId;
|
|
376
|
+
if (opts.environmentId)
|
|
377
|
+
body.environment = opts.environmentId;
|
|
378
|
+
if (opts.startTime)
|
|
379
|
+
body.startTime = opts.startTime;
|
|
380
|
+
if (opts.fromDate)
|
|
381
|
+
body.fromDate = opts.fromDate;
|
|
382
|
+
return this.post("/user/tools/scheduler", body);
|
|
383
|
+
}
|
|
384
|
+
async deleteToolScheduler(configId) {
|
|
385
|
+
return this.delete(`/user/tools/scheduler/${configId}`);
|
|
386
|
+
}
|
|
387
|
+
// --- Release gate ---
|
|
388
|
+
async releaseGateStatus(opts) {
|
|
389
|
+
const body = JSON.stringify({
|
|
390
|
+
product: opts.product,
|
|
391
|
+
subProducts: opts.subProducts,
|
|
392
|
+
env: opts.env,
|
|
393
|
+
jobName: opts.jobName ?? "mcp-check",
|
|
394
|
+
buildNumber: opts.buildNumber ?? "1",
|
|
395
|
+
current: "1",
|
|
396
|
+
end: "1",
|
|
397
|
+
jobURL: "",
|
|
398
|
+
});
|
|
399
|
+
return this.request("POST", `${this.config.url}/client/build`, undefined, body);
|
|
400
|
+
}
|
|
401
|
+
// --- HTTP ---
|
|
402
|
+
path(p) {
|
|
403
|
+
if (p.startsWith("http"))
|
|
404
|
+
return new URL(p).pathname + new URL(p).search;
|
|
405
|
+
return p.startsWith("/") ? p : `/${p}`;
|
|
406
|
+
}
|
|
407
|
+
applyQuery(url, query) {
|
|
408
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
409
|
+
if (value === undefined)
|
|
410
|
+
continue;
|
|
411
|
+
if (Array.isArray(value)) {
|
|
412
|
+
for (const item of value)
|
|
413
|
+
url.searchParams.append(key, String(item));
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
url.searchParams.set(key, String(value));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
headers() {
|
|
421
|
+
return {
|
|
422
|
+
Authorization: `Bearer ${this.config.apiToken}`,
|
|
423
|
+
"Content-Type": "application/json",
|
|
424
|
+
Accept: "application/json",
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
async request(method, url, body, rawBody) {
|
|
428
|
+
const init = { method, headers: this.headers() };
|
|
429
|
+
if (rawBody !== undefined)
|
|
430
|
+
init.body = rawBody;
|
|
431
|
+
else if (body !== undefined)
|
|
432
|
+
init.body = JSON.stringify(body);
|
|
433
|
+
const resp = await fetch(url, init);
|
|
434
|
+
if (!resp.ok) {
|
|
435
|
+
const text = await resp.text();
|
|
436
|
+
throw new Error(`ArmorCode API ${resp.status}: ${text.slice(0, 500)}`);
|
|
437
|
+
}
|
|
438
|
+
const contentType = resp.headers.get("content-type") ?? "";
|
|
439
|
+
if (!resp.headers.get("content-length") && resp.status === 204) {
|
|
440
|
+
return { status: "ok", http_code: resp.status };
|
|
441
|
+
}
|
|
442
|
+
const text = await resp.text();
|
|
443
|
+
if (!text)
|
|
444
|
+
return { status: "ok", http_code: resp.status, message: "" };
|
|
445
|
+
if (contentType.includes("application/json"))
|
|
446
|
+
return JSON.parse(text);
|
|
447
|
+
return { status: "ok", message: text };
|
|
448
|
+
}
|
|
449
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
function envFlag(name, defaultValue = false) {
|
|
2
|
+
const v = process.env[name];
|
|
3
|
+
if (v === undefined || v.trim() === "")
|
|
4
|
+
return defaultValue;
|
|
5
|
+
const n = v.trim().toLowerCase();
|
|
6
|
+
if (["0", "false", "no", "off"].includes(n))
|
|
7
|
+
return false;
|
|
8
|
+
return ["1", "true", "yes", "on"].includes(n);
|
|
9
|
+
}
|
|
10
|
+
export function loadConfig() {
|
|
11
|
+
const url = process.env.ARMORCODE_URL;
|
|
12
|
+
const apiToken = process.env.ARMORCODE_API_TOKEN ?? process.env.ARMORCODE_API_KEY;
|
|
13
|
+
if (!url)
|
|
14
|
+
throw new Error("Missing required environment variable: ARMORCODE_URL");
|
|
15
|
+
if (!apiToken) {
|
|
16
|
+
throw new Error("Missing required environment variable: ARMORCODE_API_TOKEN (or ARMORCODE_API_KEY)");
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
url: url.replace(/\/+$/, ""),
|
|
20
|
+
apiToken,
|
|
21
|
+
writeEnabled: envFlag("ARMORCODE_WRITE_ENABLED", false),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { listContent } from "./utils.js";
|
|
2
|
+
export { listContent };
|
|
3
|
+
export function parseResourceInfo(description) {
|
|
4
|
+
const marker = ["##Resource Information", "## Resource Information"].find((m) => description.includes(m));
|
|
5
|
+
if (!marker)
|
|
6
|
+
return null;
|
|
7
|
+
const block = description.slice(description.indexOf(marker));
|
|
8
|
+
const info = {};
|
|
9
|
+
for (const line of block.split("\n")) {
|
|
10
|
+
const trimmed = line.trim();
|
|
11
|
+
if (trimmed.startsWith("**") && trimmed.includes("**:")) {
|
|
12
|
+
const idx = trimmed.indexOf("**:");
|
|
13
|
+
if (idx >= 0) {
|
|
14
|
+
const key = trimmed.slice(2, idx).trim();
|
|
15
|
+
info[key] = trimmed.slice(idx + 3).trim();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return Object.keys(info).length ? info : null;
|
|
20
|
+
}
|
|
21
|
+
export function buildFindingDetail(finding) {
|
|
22
|
+
const product = finding.product ?? {};
|
|
23
|
+
const subProduct = finding.subProduct ?? {};
|
|
24
|
+
const environment = finding.environment ?? {};
|
|
25
|
+
const source = finding.source ?? {};
|
|
26
|
+
const description = String(finding.description ?? "");
|
|
27
|
+
const mitigation = String(finding.mitigation ?? "");
|
|
28
|
+
const resource = parseResourceInfo(description);
|
|
29
|
+
const detail = {
|
|
30
|
+
id: finding.id,
|
|
31
|
+
title: finding.title,
|
|
32
|
+
severity: finding.severity,
|
|
33
|
+
status: finding.status,
|
|
34
|
+
product: product.name,
|
|
35
|
+
productId: product.id,
|
|
36
|
+
subProduct: subProduct.name,
|
|
37
|
+
subProductId: subProduct.id,
|
|
38
|
+
environment: environment.name,
|
|
39
|
+
source: source.name ?? source,
|
|
40
|
+
category: finding.armorcodeCategory ?? finding.category,
|
|
41
|
+
scanType: finding.scanType,
|
|
42
|
+
riskScore: finding.riskScore ?? finding.findingScore,
|
|
43
|
+
cve: finding.cve ?? [],
|
|
44
|
+
cwes: finding.cwes ?? finding.cwe ?? [],
|
|
45
|
+
component: finding.componentName,
|
|
46
|
+
componentVersion: finding.componentVersion,
|
|
47
|
+
foundOn: finding.foundOn ?? finding.foundOnDate,
|
|
48
|
+
lastSeenDate: finding.lastSeenDate,
|
|
49
|
+
toolFindingStatus: finding.toolFindingStatus,
|
|
50
|
+
toolSeverity: finding.toolSeverity,
|
|
51
|
+
tags: finding.tags ?? [],
|
|
52
|
+
description,
|
|
53
|
+
mitigation,
|
|
54
|
+
};
|
|
55
|
+
if (resource)
|
|
56
|
+
detail.resourceInformation = resource;
|
|
57
|
+
return detail;
|
|
58
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export function normalizeSeverityList(values) {
|
|
2
|
+
const out = new Set();
|
|
3
|
+
for (const raw of values) {
|
|
4
|
+
const s = raw.trim().toUpperCase();
|
|
5
|
+
if (!s)
|
|
6
|
+
continue;
|
|
7
|
+
if (s === "CRITICAL" || s === "CRIT")
|
|
8
|
+
out.add("CRITICAL");
|
|
9
|
+
else if (s === "HIGH")
|
|
10
|
+
out.add("HIGH");
|
|
11
|
+
else if (s === "MEDIUM" || s === "MED")
|
|
12
|
+
out.add("MEDIUM");
|
|
13
|
+
else if (s === "LOW")
|
|
14
|
+
out.add("LOW");
|
|
15
|
+
else if (s === "INFO" || s === "INFORMATIONAL")
|
|
16
|
+
out.add("INFO");
|
|
17
|
+
else
|
|
18
|
+
out.add(s);
|
|
19
|
+
}
|
|
20
|
+
return [...out];
|
|
21
|
+
}
|
|
22
|
+
export function normalizeStatusList(values) {
|
|
23
|
+
return values.map((s) => s.trim().toUpperCase()).filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
/** ArmorCode AQL FindingAge uses first-detected date, not last seen. */
|
|
26
|
+
export function findingFoundTimestamp(f) {
|
|
27
|
+
return Number(f.foundOn ?? f.foundOnDate ?? 0);
|
|
28
|
+
}
|
|
29
|
+
export function findingLastSeenTimestamp(f) {
|
|
30
|
+
return Number(f.lastSeenDate ?? 0);
|
|
31
|
+
}
|
|
32
|
+
export function hasTagPrefix(tags, prefix) {
|
|
33
|
+
const p = prefix.toLowerCase();
|
|
34
|
+
return (Array.isArray(tags) ? tags : []).some((t) => String(t).toLowerCase().startsWith(p));
|
|
35
|
+
}
|
|
36
|
+
export function matchesFindingFilters(f, opts) {
|
|
37
|
+
if (opts.severity?.length) {
|
|
38
|
+
const allowed = new Set(normalizeSeverityList(opts.severity));
|
|
39
|
+
if (!allowed.has(String(f.severity ?? "").toUpperCase()))
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (opts.excludeTagPrefix && hasTagPrefix(f.tags, opts.excludeTagPrefix))
|
|
43
|
+
return false;
|
|
44
|
+
if (opts.findingAgeDays != null && opts.findingAgeDays > 0) {
|
|
45
|
+
const cutoff = (opts.nowMs ?? Date.now()) - opts.findingAgeDays * 24 * 60 * 60 * 1000;
|
|
46
|
+
const ts = findingFoundTimestamp(f);
|
|
47
|
+
if (ts > 0 && ts < cutoff)
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
export function productExploitabilityTag(exploitable) {
|
|
53
|
+
return exploitable ? "product_exploitability:exploitable" : "product_exploitability:not exploitable";
|
|
54
|
+
}
|
|
55
|
+
export function computeStats(findings) {
|
|
56
|
+
const bySeverity = {};
|
|
57
|
+
const byStatus = {};
|
|
58
|
+
for (const f of findings) {
|
|
59
|
+
const sev = String(f.severity ?? "UNKNOWN").toUpperCase();
|
|
60
|
+
const st = String(f.status ?? "UNKNOWN").toUpperCase();
|
|
61
|
+
bySeverity[sev] = (bySeverity[sev] ?? 0) + 1;
|
|
62
|
+
byStatus[st] = (byStatus[st] ?? 0) + 1;
|
|
63
|
+
}
|
|
64
|
+
return { total: findings.length, bySeverity, byStatus, source: "computed_from_search" };
|
|
65
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { ArmorCodeClient } from "./client.js";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import { registerArmorCodeTools } from "./tools/registerTools.js";
|
|
7
|
+
const config = loadConfig();
|
|
8
|
+
const client = new ArmorCodeClient(config);
|
|
9
|
+
const server = new McpServer({
|
|
10
|
+
name: "armorcode-mcp",
|
|
11
|
+
version: "1.0.0",
|
|
12
|
+
});
|
|
13
|
+
registerArmorCodeTools(server, client);
|
|
14
|
+
const transport = new StdioServerTransport();
|
|
15
|
+
await server.connect(transport);
|
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { listContent } from "./utils.js";
|
|
2
|
+
export async function resolveProductSubproduct(client, productName = "", subproductName = "") {
|
|
3
|
+
let productIds;
|
|
4
|
+
if (productName) {
|
|
5
|
+
const products = await client.listProducts(productName);
|
|
6
|
+
const content = listContent(products);
|
|
7
|
+
const match = content.find((p) => String(p.name).toLowerCase() === productName.toLowerCase());
|
|
8
|
+
if (match?.id != null)
|
|
9
|
+
productIds = [Number(match.id)];
|
|
10
|
+
else if (content[0]?.id != null)
|
|
11
|
+
productIds = [Number(content[0].id)];
|
|
12
|
+
}
|
|
13
|
+
let subproductIds;
|
|
14
|
+
if (subproductName) {
|
|
15
|
+
const subproducts = await client.listSubproducts({ productIds, search: subproductName });
|
|
16
|
+
const spList = listContent(subproducts);
|
|
17
|
+
const match = spList.find((sp) => String(sp.name).toLowerCase() === subproductName.toLowerCase());
|
|
18
|
+
if (match?.id != null)
|
|
19
|
+
subproductIds = [Number(match.id)];
|
|
20
|
+
else if (spList[0]?.id != null)
|
|
21
|
+
subproductIds = [Number(spList[0].id)];
|
|
22
|
+
}
|
|
23
|
+
return { productIds, subproductIds };
|
|
24
|
+
}
|
|
25
|
+
export async function resolveProductId(client, productName) {
|
|
26
|
+
const products = await client.listProducts(productName);
|
|
27
|
+
const content = listContent(products);
|
|
28
|
+
const match = content.find((p) => String(p.name).toLowerCase() === productName.toLowerCase());
|
|
29
|
+
if (match?.id != null)
|
|
30
|
+
return Number(match.id);
|
|
31
|
+
if (content[0]?.id != null)
|
|
32
|
+
return Number(content[0].id);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
export async function resolveSubproductId(client, subproductName, productName = "") {
|
|
36
|
+
const { productIds } = productName ? await resolveProductSubproduct(client, productName) : {};
|
|
37
|
+
const subproducts = await client.listSubproducts({ productIds, search: subproductName });
|
|
38
|
+
const spList = listContent(subproducts);
|
|
39
|
+
const match = spList.find((sp) => String(sp.name).toLowerCase() === subproductName.toLowerCase());
|
|
40
|
+
if (match?.id != null)
|
|
41
|
+
return Number(match.id);
|
|
42
|
+
if (spList[0]?.id != null)
|
|
43
|
+
return Number(spList[0].id);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function jsonResult(data) {
|
|
2
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
3
|
+
}
|
|
4
|
+
export function resolveWriteMode(requestedDryRun, writeEnabled) {
|
|
5
|
+
if (requestedDryRun !== false)
|
|
6
|
+
return { dryRun: true };
|
|
7
|
+
if (!writeEnabled) {
|
|
8
|
+
return {
|
|
9
|
+
dryRun: true,
|
|
10
|
+
notice: "Writes disabled (ARMORCODE_WRITE_ENABLED not set or false). Preview only.",
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
return { dryRun: false };
|
|
14
|
+
}
|
|
15
|
+
export function splitCsv(value) {
|
|
16
|
+
const items = value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
17
|
+
return items.length ? items : undefined;
|
|
18
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { buildFindingDetail, listContent } from "../findingDetail.js";
|
|
3
|
+
import { resolveProductId, resolveProductSubproduct, resolveSubproductId } from "../resolve.js";
|
|
4
|
+
import { jsonResult, resolveWriteMode, splitCsv } from "./common.js";
|
|
5
|
+
import { hasTagPrefix, productExploitabilityTag } from "../findingFilters.js";
|
|
6
|
+
function findingsFromSearch(data) {
|
|
7
|
+
if (data && typeof data === "object") {
|
|
8
|
+
const o = data;
|
|
9
|
+
const nested = o.data;
|
|
10
|
+
if (nested && Array.isArray(nested.findings))
|
|
11
|
+
return nested.findings;
|
|
12
|
+
}
|
|
13
|
+
return listContent(data);
|
|
14
|
+
}
|
|
15
|
+
function summarizeFinding(f) {
|
|
16
|
+
const product = f.product ?? {};
|
|
17
|
+
const subProduct = f.subProduct ?? {};
|
|
18
|
+
const source = f.source ?? {};
|
|
19
|
+
return {
|
|
20
|
+
id: f.id,
|
|
21
|
+
title: f.title,
|
|
22
|
+
severity: f.severity,
|
|
23
|
+
status: f.status,
|
|
24
|
+
product: product.name,
|
|
25
|
+
subProduct: subProduct.name,
|
|
26
|
+
source: source.name ?? source,
|
|
27
|
+
cve: f.cve,
|
|
28
|
+
tags: f.tags ?? [],
|
|
29
|
+
component: f.componentName,
|
|
30
|
+
componentVersion: f.componentVersion,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async function statusTransition(client, dryRun, findingId, action, apply, newStatus, extra = {}) {
|
|
34
|
+
if (dryRun) {
|
|
35
|
+
return jsonResult({ success: true, dry_run: true, finding_id: findingId, action, new_status: newStatus, ...extra });
|
|
36
|
+
}
|
|
37
|
+
const response = await apply();
|
|
38
|
+
return jsonResult({ success: true, finding_id: findingId, new_status: newStatus, response, ...extra });
|
|
39
|
+
}
|
|
40
|
+
export function registerArmorCodeTools(server, client) {
|
|
41
|
+
server.registerTool("armorcode_health", { title: "ArmorCode Health", description: "Verify ArmorCode API connectivity.", inputSchema: {} }, async () => jsonResult(await client.listProducts("", 1)));
|
|
42
|
+
server.registerTool("armorcode_search_findings", {
|
|
43
|
+
title: "Search Findings",
|
|
44
|
+
description: "Search/filter ArmorCode findings. Severity and excludeTagPrefix are applied client-side when server filters fail. Use fetchAll=true to paginate.",
|
|
45
|
+
inputSchema: {
|
|
46
|
+
productName: z.string().optional(),
|
|
47
|
+
subproductName: z.string().optional(),
|
|
48
|
+
severity: z.string().optional(),
|
|
49
|
+
status: z.string().optional(),
|
|
50
|
+
sourceTool: z.string().optional(),
|
|
51
|
+
tags: z.string().optional(),
|
|
52
|
+
cves: z.string().optional(),
|
|
53
|
+
pageSize: z.number().int().positive().max(100).optional(),
|
|
54
|
+
afterKey: z.number().int().optional(),
|
|
55
|
+
fetchAll: z.boolean().optional(),
|
|
56
|
+
maxPages: z.number().int().positive().max(50).optional(),
|
|
57
|
+
excludeTagPrefix: z.string().optional(),
|
|
58
|
+
findingAgeDays: z.number().int().positive().max(365).optional(),
|
|
59
|
+
},
|
|
60
|
+
}, async ({ productName, subproductName, severity, status, sourceTool, tags, cves, pageSize, afterKey, fetchAll, maxPages, excludeTagPrefix, findingAgeDays, }) => {
|
|
61
|
+
const { productIds, subproductIds } = await resolveProductSubproduct(client, productName ?? "", subproductName ?? "");
|
|
62
|
+
const searchOpts = {
|
|
63
|
+
productIds,
|
|
64
|
+
subproductIds,
|
|
65
|
+
severity: splitCsv(severity ?? ""),
|
|
66
|
+
status: splitCsv(status ?? ""),
|
|
67
|
+
sourceTools: sourceTool ? [sourceTool] : undefined,
|
|
68
|
+
tags: splitCsv(tags ?? ""),
|
|
69
|
+
cves: splitCsv(cves ?? ""),
|
|
70
|
+
size: pageSize ?? 50,
|
|
71
|
+
afterKey,
|
|
72
|
+
excludeTagPrefix: excludeTagPrefix ?? undefined,
|
|
73
|
+
findingAgeDays: findingAgeDays ?? undefined,
|
|
74
|
+
};
|
|
75
|
+
if (fetchAll) {
|
|
76
|
+
const result = await client.searchFindingsAll({ ...searchOpts, maxPages: maxPages ?? 20, size: pageSize ?? 100 });
|
|
77
|
+
const findings = result.findings.map(summarizeFinding);
|
|
78
|
+
return jsonResult({
|
|
79
|
+
returned: findings.length,
|
|
80
|
+
pagesFetched: result.pagesFetched,
|
|
81
|
+
totalFetched: result.totalFetched,
|
|
82
|
+
findings,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const data = await client.searchFindings(searchOpts);
|
|
86
|
+
const findings = findingsFromSearch(data).map(summarizeFinding);
|
|
87
|
+
const raw = data;
|
|
88
|
+
const meta = raw.data ?? {};
|
|
89
|
+
return jsonResult({
|
|
90
|
+
returned: findings.length,
|
|
91
|
+
findings,
|
|
92
|
+
afterKey: meta.afterKey,
|
|
93
|
+
clientFiltered: meta.clientFiltered ?? false,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
server.registerTool("armorcode_get_finding", { title: "Get Finding", description: "Full finding details by ID.", inputSchema: { findingId: z.number().int().positive() } }, async ({ findingId }) => jsonResult(buildFindingDetail((await client.getFinding(findingId)))));
|
|
97
|
+
server.registerTool("armorcode_get_finding_events", { title: "Finding Events", description: "Audit events for a finding.", inputSchema: { findingId: z.number().int().positive() } }, async ({ findingId }) => jsonResult(await client.getFindingEvents(findingId)));
|
|
98
|
+
const statusTool = (name, title, newStatus, apply) => {
|
|
99
|
+
server.registerTool(name, { title, description: `${title}. dryRun defaults true.`, inputSchema: { findingId: z.number().int().positive(), reason: z.string().optional(), notes: z.string().optional(), dryRun: z.boolean().optional() } }, async ({ findingId, reason, notes, dryRun }) => {
|
|
100
|
+
const { dryRun: dry, notice } = resolveWriteMode(dryRun, client.writeEnabled);
|
|
101
|
+
const result = await statusTransition(client, dry, findingId, name, () => apply(findingId, reason ?? "", notes ?? ""), newStatus, { notice });
|
|
102
|
+
return result;
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
statusTool("armorcode_confirm_finding", "Confirm Finding", "CONFIRMED", (id, r, n) => client.confirmFinding(id, r, n));
|
|
106
|
+
statusTool("armorcode_control_finding", "Control Finding", "CONTROLLED", (id, r, n) => client.controlFinding(id, r, n));
|
|
107
|
+
statusTool("armorcode_accept_risk", "Accept Risk", "ACCEPTRISK", (id, r, n) => client.acceptRiskFinding(id, r, n));
|
|
108
|
+
statusTool("armorcode_mark_false_positive", "False Positive", "FALSEPOSITIVE", (id, r, n) => client.falsePositiveFinding(id, r, n));
|
|
109
|
+
statusTool("armorcode_reopen_finding", "Reopen Finding", "OPEN", (id, r, n) => client.openFinding(id, r, n));
|
|
110
|
+
statusTool("armorcode_triage_finding", "Triage Finding", "TRIAGE", (id, r, n) => client.triageFinding(id, r, n));
|
|
111
|
+
statusTool("armorcode_mitigate_finding", "Mitigate Finding", "MITIGATED", (id, r, n) => client.mitigateFinding(id, r, n));
|
|
112
|
+
statusTool("armorcode_suppress_finding", "Suppress Finding", "SUPPRESSED", (id, r, n) => client.suppressFinding(id, r, n));
|
|
113
|
+
server.registerTool("armorcode_block_finding", { title: "Block Finding", description: "Block via accept-risk (fix unavailable). dryRun defaults true.", inputSchema: { findingId: z.number().int().positive(), reason: z.string().optional(), notes: z.string().optional(), dryRun: z.boolean().optional() } }, async ({ findingId, reason, notes, dryRun }) => {
|
|
114
|
+
const { dryRun: dry, notice } = resolveWriteMode(dryRun, client.writeEnabled);
|
|
115
|
+
return statusTransition(client, dry, findingId, "block", () => client.acceptRiskFinding(findingId, reason ?? "Fix Unavailable", notes ?? ""), "BLOCKED", { notice, reason: reason ?? "Fix Unavailable" });
|
|
116
|
+
});
|
|
117
|
+
server.registerTool("armorcode_add_comment", { title: "Add Comment", description: "Add comment to finding. dryRun defaults true.", inputSchema: { findingId: z.number().int().positive(), note: z.string().min(1), dryRun: z.boolean().optional() } }, async ({ findingId, note, dryRun }) => {
|
|
118
|
+
const { dryRun: dry, notice } = resolveWriteMode(dryRun, client.writeEnabled);
|
|
119
|
+
if (dry)
|
|
120
|
+
return jsonResult({ success: true, dry_run: true, notice, finding_id: findingId, note });
|
|
121
|
+
return jsonResult({ success: true, finding_id: findingId, response: await client.addComment(findingId, note) });
|
|
122
|
+
});
|
|
123
|
+
server.registerTool("armorcode_add_exploitability_comment", { title: "Exploitability Comment", description: "Structured exploitable/notexploitable comment. dryRun defaults true.", inputSchema: { findingId: z.number().int().positive(), exploitable: z.boolean(), reason: z.string().min(1), dryRun: z.boolean().optional() } }, async ({ findingId, exploitable, reason, dryRun }) => {
|
|
124
|
+
const label = exploitable ? "exploitable" : "notexploitable";
|
|
125
|
+
const comment = `${label}\nreason: ${reason}`;
|
|
126
|
+
const { dryRun: dry, notice } = resolveWriteMode(dryRun, client.writeEnabled);
|
|
127
|
+
if (dry)
|
|
128
|
+
return jsonResult({ success: true, dry_run: true, notice, finding_id: findingId, comment });
|
|
129
|
+
return jsonResult({ success: true, finding_id: findingId, comment, response: await client.addComment(findingId, comment) });
|
|
130
|
+
});
|
|
131
|
+
server.registerTool("armorcode_add_finding_tags", {
|
|
132
|
+
title: "Add Finding Tags",
|
|
133
|
+
description: "Add/remove finding tags only (no comment). For exploitability triage use armorcode_set_product_exploitability. dryRun defaults true.",
|
|
134
|
+
inputSchema: {
|
|
135
|
+
findingId: z.number().int().positive(),
|
|
136
|
+
tags: z.string().min(1),
|
|
137
|
+
removeTags: z.string().optional(),
|
|
138
|
+
dryRun: z.boolean().optional(),
|
|
139
|
+
},
|
|
140
|
+
}, async ({ findingId, tags, removeTags, dryRun }) => {
|
|
141
|
+
const added = splitCsv(tags) ?? [];
|
|
142
|
+
const removed = splitCsv(removeTags ?? "") ?? [];
|
|
143
|
+
const { dryRun: dry, notice } = resolveWriteMode(dryRun, client.writeEnabled);
|
|
144
|
+
const payload = { finding_id: findingId, added, removed };
|
|
145
|
+
if (dry)
|
|
146
|
+
return jsonResult({ success: true, dry_run: true, notice, ...payload });
|
|
147
|
+
return jsonResult({ success: true, ...payload, response: await client.updateFindingTags(findingId, { added, removed }) });
|
|
148
|
+
});
|
|
149
|
+
server.registerTool("armorcode_set_product_exploitability", {
|
|
150
|
+
title: "Set Product Exploitability",
|
|
151
|
+
description: "Triage exploitability: adds comment first, then product_exploitability tag. dryRun defaults true.",
|
|
152
|
+
inputSchema: {
|
|
153
|
+
findingId: z.number().int().positive(),
|
|
154
|
+
exploitable: z.boolean(),
|
|
155
|
+
reason: z.string().min(1),
|
|
156
|
+
dryRun: z.boolean().optional(),
|
|
157
|
+
},
|
|
158
|
+
}, async ({ findingId, exploitable, reason, dryRun }) => {
|
|
159
|
+
const tag = productExploitabilityTag(exploitable);
|
|
160
|
+
const label = exploitable ? "exploitable" : "notexploitable";
|
|
161
|
+
const comment = `${label}\nreason: ${reason}`;
|
|
162
|
+
const { dryRun: dry, notice } = resolveWriteMode(dryRun, client.writeEnabled);
|
|
163
|
+
const payload = { finding_id: findingId, exploitable, tag, comment };
|
|
164
|
+
if (dry)
|
|
165
|
+
return jsonResult({ success: true, dry_run: true, notice, ...payload });
|
|
166
|
+
return jsonResult({ success: true, ...payload, response: await client.setProductExploitability(findingId, exploitable, reason) });
|
|
167
|
+
});
|
|
168
|
+
server.registerTool("armorcode_bulk_set_product_exploitability", {
|
|
169
|
+
title: "Bulk Set Product Exploitability",
|
|
170
|
+
description: "Triage multiple findings: comment first, then product_exploitability tag per finding. dryRun defaults true.",
|
|
171
|
+
inputSchema: {
|
|
172
|
+
productName: z.string().min(1),
|
|
173
|
+
subproductName: z.string().optional(),
|
|
174
|
+
severity: z.string().optional(),
|
|
175
|
+
status: z.string().optional().default("OPEN"),
|
|
176
|
+
findingAgeDays: z.number().int().positive().max(365).optional().default(30),
|
|
177
|
+
exploitable: z.boolean().optional().default(false),
|
|
178
|
+
reason: z.string().min(1),
|
|
179
|
+
maxFindings: z.number().int().positive().max(200).optional().default(50),
|
|
180
|
+
dryRun: z.boolean().optional(),
|
|
181
|
+
},
|
|
182
|
+
}, async ({ productName, subproductName, severity, status, findingAgeDays, exploitable, reason, maxFindings, dryRun }) => {
|
|
183
|
+
const { productIds, subproductIds } = await resolveProductSubproduct(client, productName, subproductName ?? "");
|
|
184
|
+
const { findings, pagesFetched, totalFetched } = await client.searchFindingsAll({
|
|
185
|
+
productIds,
|
|
186
|
+
subproductIds,
|
|
187
|
+
status: splitCsv(status ?? "OPEN"),
|
|
188
|
+
severity: splitCsv(severity ?? "CRITICAL,HIGH"),
|
|
189
|
+
excludeTagPrefix: "product_exploitability",
|
|
190
|
+
findingAgeDays: findingAgeDays ?? 30,
|
|
191
|
+
maxPages: 20,
|
|
192
|
+
size: 100,
|
|
193
|
+
});
|
|
194
|
+
const targets = findings.slice(0, maxFindings ?? 50);
|
|
195
|
+
const tag = productExploitabilityTag(exploitable ?? false);
|
|
196
|
+
const label = exploitable ? "exploitable" : "notexploitable";
|
|
197
|
+
const comment = `${label}\nreason: ${reason}`;
|
|
198
|
+
const { dryRun: dry, notice } = resolveWriteMode(dryRun, client.writeEnabled);
|
|
199
|
+
const planned = targets.map((f) => ({
|
|
200
|
+
id: f.id,
|
|
201
|
+
severity: f.severity,
|
|
202
|
+
title: String(f.title ?? "").slice(0, 100),
|
|
203
|
+
component: f.componentName,
|
|
204
|
+
alreadyTagged: hasTagPrefix(f.tags, "product_exploitability"),
|
|
205
|
+
tag,
|
|
206
|
+
comment,
|
|
207
|
+
}));
|
|
208
|
+
if (dry) {
|
|
209
|
+
return jsonResult({
|
|
210
|
+
success: true,
|
|
211
|
+
dry_run: true,
|
|
212
|
+
notice,
|
|
213
|
+
matched: findings.length,
|
|
214
|
+
pagesFetched,
|
|
215
|
+
totalFetched,
|
|
216
|
+
planned_count: planned.length,
|
|
217
|
+
planned,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const results = [];
|
|
221
|
+
for (const f of targets) {
|
|
222
|
+
try {
|
|
223
|
+
const response = await client.setProductExploitability(Number(f.id), exploitable ?? false, reason);
|
|
224
|
+
results.push({ id: f.id, success: true, response });
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
results.push({ id: f.id, success: false, error: err instanceof Error ? err.message : String(err) });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return jsonResult({ success: true, matched: findings.length, applied: results.length, results });
|
|
231
|
+
});
|
|
232
|
+
server.registerTool("armorcode_update_finding_severity", { title: "Update Severity", description: "Update finding severity. dryRun defaults true.", inputSchema: { findingId: z.number().int().positive(), severity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]), reason: z.string().optional(), notes: z.string().optional(), dryRun: z.boolean().optional() } }, async ({ findingId, severity, reason, notes, dryRun }) => {
|
|
233
|
+
const { dryRun: dry, notice } = resolveWriteMode(dryRun, client.writeEnabled);
|
|
234
|
+
if (dry)
|
|
235
|
+
return jsonResult({ success: true, dry_run: true, notice, finding_id: findingId, new_severity: severity });
|
|
236
|
+
return jsonResult({ success: true, finding_id: findingId, new_severity: severity, response: await client.updateSeverity(findingId, severity, reason ?? "", notes ?? "") });
|
|
237
|
+
});
|
|
238
|
+
server.registerTool("armorcode_get_findings_stats", { title: "Findings Stats", description: "Finding counts by severity/status.", inputSchema: { productName: z.string().optional(), subproductName: z.string().optional(), severity: z.string().optional(), status: z.string().optional(), sourceTool: z.string().optional() } }, async (args) => {
|
|
239
|
+
const { productIds, subproductIds } = await resolveProductSubproduct(client, args.productName ?? "", args.subproductName ?? "");
|
|
240
|
+
return jsonResult(await client.getFindingStats({ productIds, subproductIds, severity: splitCsv(args.severity ?? ""), status: splitCsv(args.status ?? ""), sourceTools: args.sourceTool ? [args.sourceTool] : undefined }));
|
|
241
|
+
});
|
|
242
|
+
server.registerTool("armorcode_get_severity_stats", { title: "Severity Stats", description: "Severity breakdown for product/subproduct.", inputSchema: { productName: z.string().optional(), subproductName: z.string().optional() } }, async ({ productName, subproductName }) => {
|
|
243
|
+
const { productIds, subproductIds } = await resolveProductSubproduct(client, productName ?? "", subproductName ?? "");
|
|
244
|
+
return jsonResult(await client.getSeverityStats(productIds, subproductIds));
|
|
245
|
+
});
|
|
246
|
+
server.registerTool("armorcode_list_products", { title: "List Products", description: "List ArmorCode products/groups.", inputSchema: { search: z.string().optional() } }, async ({ search }) => jsonResult(await client.listProducts(search ?? "")));
|
|
247
|
+
server.registerTool("armorcode_list_subproducts", { title: "List Subproducts", description: "List subproducts, optionally by product.", inputSchema: { productName: z.string().optional(), search: z.string().optional() } }, async ({ productName, search }) => {
|
|
248
|
+
const { productIds } = await resolveProductSubproduct(client, productName ?? "");
|
|
249
|
+
return jsonResult(await client.listSubproducts({ productIds, search: search ?? "" }));
|
|
250
|
+
});
|
|
251
|
+
server.registerTool("armorcode_get_product_risk_score", { title: "Product Risk Score", description: "Risk score for a product by name.", inputSchema: { productName: z.string().min(1), environment: z.string().optional() } }, async ({ productName, environment }) => {
|
|
252
|
+
const productId = await resolveProductId(client, productName);
|
|
253
|
+
if (!productId)
|
|
254
|
+
return jsonResult({ error: `Product '${productName}' not found` });
|
|
255
|
+
return jsonResult(await client.getProductRiskScore(productId, environment ?? ""));
|
|
256
|
+
});
|
|
257
|
+
server.registerTool("armorcode_get_subproduct_risk_score", { title: "Subproduct Risk Score", description: "Risk score for a subproduct.", inputSchema: { subproductName: z.string().min(1), productName: z.string().optional(), environment: z.string().optional() } }, async ({ subproductName, productName, environment }) => {
|
|
258
|
+
const subproductId = await resolveSubproductId(client, subproductName, productName ?? "");
|
|
259
|
+
if (!subproductId)
|
|
260
|
+
return jsonResult({ error: `Subproduct '${subproductName}' not found` });
|
|
261
|
+
return jsonResult(await client.getSubproductRiskScore(subproductId, environment ?? ""));
|
|
262
|
+
});
|
|
263
|
+
server.registerTool("armorcode_get_all_product_risk_scores", { title: "All Product Risk Scores", description: "Risk scores for all products.", inputSchema: { environment: z.string().optional() } }, async ({ environment }) => jsonResult(await client.getAllProductRiskScores(environment ?? "")));
|
|
264
|
+
server.registerTool("armorcode_list_scans", { title: "List Scans", description: "List security scans with filters.", inputSchema: { productName: z.string().optional(), subproductName: z.string().optional(), toolName: z.string().optional(), environment: z.string().optional(), scanStatus: z.string().optional(), pageSize: z.number().int().positive().max(100).optional() } }, async ({ productName, subproductName, toolName, environment, scanStatus, pageSize }) => {
|
|
265
|
+
const { productIds, subproductIds } = await resolveProductSubproduct(client, productName ?? "", subproductName ?? "");
|
|
266
|
+
return jsonResult(await client.listScans({
|
|
267
|
+
productIds,
|
|
268
|
+
subproductIds,
|
|
269
|
+
toolNames: toolName ? [toolName] : undefined,
|
|
270
|
+
environmentNames: environment ? [environment] : undefined,
|
|
271
|
+
scanStatuses: splitCsv(scanStatus ?? ""),
|
|
272
|
+
size: pageSize ?? 20,
|
|
273
|
+
}));
|
|
274
|
+
});
|
|
275
|
+
server.registerTool("armorcode_get_scan", { title: "Get Scan", description: "Scan details by ID.", inputSchema: { scanId: z.number().int().positive() } }, async ({ scanId }) => jsonResult(await client.getScan(scanId)));
|
|
276
|
+
server.registerTool("armorcode_execute_scan", { title: "Execute Scan", description: "Trigger scan for subproduct. dryRun defaults true.", inputSchema: { subproductName: z.string().min(1), toolName: z.string().min(1), environment: z.string().min(1), productName: z.string().optional(), dryRun: z.boolean().optional() } }, async ({ subproductName, toolName, environment, productName, dryRun }) => {
|
|
277
|
+
const { dryRun: dry, notice } = resolveWriteMode(dryRun, client.writeEnabled);
|
|
278
|
+
const subproductId = await resolveSubproductId(client, subproductName, productName ?? "");
|
|
279
|
+
if (!subproductId)
|
|
280
|
+
return jsonResult({ error: `Subproduct '${subproductName}' not found` });
|
|
281
|
+
if (dry)
|
|
282
|
+
return jsonResult({ success: true, dry_run: true, notice, subproduct_id: subproductId, tool: toolName, environment });
|
|
283
|
+
return jsonResult({ success: true, response: await client.executeScan(subproductId, toolName, environment) });
|
|
284
|
+
});
|
|
285
|
+
server.registerTool("armorcode_list_exceptions", { title: "List Exceptions", description: "Open risk register exceptions.", inputSchema: {} }, async () => {
|
|
286
|
+
const result = await client.listOpenExceptions();
|
|
287
|
+
const items = Array.isArray(result) ? result : listContent(result);
|
|
288
|
+
return jsonResult({ total: items.length, exceptions: items });
|
|
289
|
+
});
|
|
290
|
+
server.registerTool("armorcode_get_exception", { title: "Get Exception", description: "Exception details by ID.", inputSchema: { exceptionId: z.number().int().positive() } }, async ({ exceptionId }) => jsonResult(await client.getException(exceptionId)));
|
|
291
|
+
server.registerTool("armorcode_create_exception", { title: "Create Exception", description: "Create risk register exception. dryRun defaults true.", inputSchema: { name: z.string().min(1), description: z.string().min(1), startDate: z.string().min(1), endDate: z.string().min(1), productName: z.string().min(1), reasons: z.string().optional(), subproductName: z.string().optional(), environment: z.string().optional(), dryRun: z.boolean().optional() } }, async (args) => {
|
|
292
|
+
const { dryRun: dry, notice } = resolveWriteMode(args.dryRun, client.writeEnabled);
|
|
293
|
+
const productId = await resolveProductId(client, args.productName);
|
|
294
|
+
if (!productId)
|
|
295
|
+
return jsonResult({ error: `Product '${args.productName}' not found` });
|
|
296
|
+
let subproductIds;
|
|
297
|
+
if (args.subproductName) {
|
|
298
|
+
const spId = await resolveSubproductId(client, args.subproductName, args.productName);
|
|
299
|
+
if (spId)
|
|
300
|
+
subproductIds = [spId];
|
|
301
|
+
}
|
|
302
|
+
const payload = { name: args.name, description: args.description, startDate: args.startDate, endDate: args.endDate, productId, reasons: splitCsv(args.reasons ?? "Fix Unavailable"), subproductIds, environmentName: args.environment };
|
|
303
|
+
if (dry)
|
|
304
|
+
return jsonResult({ success: true, dry_run: true, notice, payload });
|
|
305
|
+
return jsonResult({ success: true, exception: await client.createException({ ...payload, reasons: payload.reasons ?? ["Fix Unavailable"] }) });
|
|
306
|
+
});
|
|
307
|
+
server.registerTool("armorcode_trigger_scan_pull", { title: "Trigger Scan Pull", description: "Create temporary scheduler for PULL tools (e.g. Black Duck). dryRun defaults true.", inputSchema: { toolName: z.string().min(1), productName: z.string().min(1), subproductName: z.string().min(1), environment: z.string().optional(), dryRun: z.boolean().optional() } }, async ({ toolName, productName, subproductName, environment, dryRun }) => {
|
|
308
|
+
const { dryRun: dry, notice } = resolveWriteMode(dryRun, client.writeEnabled);
|
|
309
|
+
const productId = await resolveProductId(client, productName);
|
|
310
|
+
if (!productId)
|
|
311
|
+
return jsonResult({ error: `Product '${productName}' not found` });
|
|
312
|
+
const subproductId = await resolveSubproductId(client, subproductName, productName);
|
|
313
|
+
if (!subproductId)
|
|
314
|
+
return jsonResult({ error: `Subproduct '${subproductName}' not found` });
|
|
315
|
+
const envs = (await client.listEnvironments(subproductId));
|
|
316
|
+
const envList = Array.isArray(envs) ? envs : listContent(envs);
|
|
317
|
+
const envName = environment ?? "Production";
|
|
318
|
+
const envMatch = envList.find((e) => String(e.name).toLowerCase() === envName.toLowerCase());
|
|
319
|
+
const envId = envMatch?.id != null ? Number(envMatch.id) : envList[0]?.id != null ? Number(envList[0].id) : undefined;
|
|
320
|
+
const startTime = new Date(Date.now() + 60_000).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true, timeZone: "UTC" });
|
|
321
|
+
if (dry) {
|
|
322
|
+
return jsonResult({ success: true, dry_run: true, notice, tool: toolName, product_id: productId, subproduct_id: subproductId, environment_id: envId, start_time_utc: startTime });
|
|
323
|
+
}
|
|
324
|
+
await client.createToolScheduler({ toolName, productId, subproductId, environmentId: envId, startTime, timezone: "UTC", fromDate: new Date().toISOString() });
|
|
325
|
+
const schedulers = await client.listToolSchedulers(toolName);
|
|
326
|
+
const list = listContent(schedulers);
|
|
327
|
+
const scheduler = list.find((s) => s.product === productId && s.subProduct === subproductId);
|
|
328
|
+
return jsonResult({ success: true, scheduler_id: scheduler?.id, message: "Scan pull scheduler created. Use armorcode_list_scan_schedulers and armorcode_delete_scan_scheduler to clean up." });
|
|
329
|
+
});
|
|
330
|
+
server.registerTool("armorcode_list_scan_schedulers", { title: "List Scan Schedulers", description: "Schedulers for a security tool.", inputSchema: { toolName: z.string().min(1) } }, async ({ toolName }) => {
|
|
331
|
+
const schedulers = await client.listToolSchedulers(toolName);
|
|
332
|
+
const defaultSchedule = await client.getDefaultToolScheduler(toolName);
|
|
333
|
+
return jsonResult({ custom_schedulers: listContent(schedulers), default_schedule: defaultSchedule });
|
|
334
|
+
});
|
|
335
|
+
server.registerTool("armorcode_delete_scan_scheduler", { title: "Delete Scan Scheduler", description: "Delete scheduler config. dryRun defaults true.", inputSchema: { schedulerId: z.number().int().positive(), dryRun: z.boolean().optional() } }, async ({ schedulerId, dryRun }) => {
|
|
336
|
+
const { dryRun: dry, notice } = resolveWriteMode(dryRun, client.writeEnabled);
|
|
337
|
+
if (dry)
|
|
338
|
+
return jsonResult({ success: true, dry_run: true, notice, scheduler_id: schedulerId });
|
|
339
|
+
return jsonResult({ success: true, scheduler_id: schedulerId, response: await client.deleteToolScheduler(schedulerId) });
|
|
340
|
+
});
|
|
341
|
+
server.registerTool("armorcode_release_gate_status", { title: "Release Gate Status", description: "Check ArmorCode release gate (/client/build).", inputSchema: { product: z.string().min(1), subProducts: z.array(z.string().min(1)).min(1), env: z.string().min(1) } }, async ({ product, subProducts, env }) => jsonResult(await client.releaseGateStatus({ product, subProducts, env })));
|
|
342
|
+
server.registerTool("armorcode_api", { title: "ArmorCode API", description: "Escape hatch for any ArmorCode REST path.", inputSchema: { method: z.enum(["GET", "POST", "PUT", "DELETE"]), path: z.string().min(1), body: z.record(z.unknown()).optional(), dryRun: z.boolean().optional() } }, async ({ method, path, body, dryRun }) => {
|
|
343
|
+
const readOnlyPost = method === "POST" && /^\/api\/findings\b/i.test(path);
|
|
344
|
+
if (method !== "GET" && !readOnlyPost) {
|
|
345
|
+
const { dryRun: dry, notice } = resolveWriteMode(dryRun, client.writeEnabled);
|
|
346
|
+
if (dry)
|
|
347
|
+
return jsonResult({ dry_run: true, notice, method, path, body });
|
|
348
|
+
}
|
|
349
|
+
switch (method) {
|
|
350
|
+
case "GET":
|
|
351
|
+
return jsonResult(await client.get(path));
|
|
352
|
+
case "POST":
|
|
353
|
+
return jsonResult(await client.post(path, body));
|
|
354
|
+
case "PUT":
|
|
355
|
+
return jsonResult(await client.put(path, body));
|
|
356
|
+
case "DELETE":
|
|
357
|
+
return jsonResult(await client.delete(path));
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function listContent(data) {
|
|
2
|
+
if (Array.isArray(data))
|
|
3
|
+
return data;
|
|
4
|
+
if (data && typeof data === "object") {
|
|
5
|
+
const o = data;
|
|
6
|
+
if (Array.isArray(o.content))
|
|
7
|
+
return o.content;
|
|
8
|
+
if (Array.isArray(o.data))
|
|
9
|
+
return o.data;
|
|
10
|
+
const nested = o.data;
|
|
11
|
+
if (nested && Array.isArray(nested.findings))
|
|
12
|
+
return nested.findings;
|
|
13
|
+
}
|
|
14
|
+
return [];
|
|
15
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@raviraj87/armorcode-mcp",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Local MCP server for ArmorCode finding triage, scans, exceptions, and risk workflows",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Ravi Raj",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/raviraj-ntp/armorcode-mcp.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/raviraj-ntp/armorcode-mcp#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/raviraj-ntp/armorcode-mcp/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"model-context-protocol",
|
|
24
|
+
"armorcode",
|
|
25
|
+
"aspm",
|
|
26
|
+
"security",
|
|
27
|
+
"cursor"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"main": "dist/index.js",
|
|
31
|
+
"bin": {
|
|
32
|
+
"armorcode-mcp": "dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -p tsconfig.json",
|
|
41
|
+
"prepublishOnly": "npm run build",
|
|
42
|
+
"start": "node dist/index.js",
|
|
43
|
+
"dev": "tsx src/index.ts",
|
|
44
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
45
|
+
"test:readonly": "node scripts/test-readonly.mjs"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
49
|
+
"node-fetch": "^3.3.2",
|
|
50
|
+
"zod": "^3.23.8"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^22.15.21",
|
|
54
|
+
"tsx": "^4.19.4",
|
|
55
|
+
"typescript": "^5.8.3"
|
|
56
|
+
}
|
|
57
|
+
}
|