@apitella/scan 0.1.0 → 0.2.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 +26 -7
- package/dist/analyze.js +15 -0
- package/dist/baseline.js +0 -0
- package/dist/index.js +35 -12
- package/dist/report.js +68 -12
- package/dist/sarif.js +94 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -39,10 +39,13 @@ Apitella scan my-warehouse-mcp v1.4.0
|
|
|
39
39
|
| `--token <t>` | Bearer token shorthand. |
|
|
40
40
|
| `--baseline [file]` | Diff the scan against a committed snapshot (default `.apitella/baseline.json`). |
|
|
41
41
|
| `--update-baseline` | Write the current surface to the baseline file and exit 0. |
|
|
42
|
-
| `--
|
|
42
|
+
| `--format <fmt>` | `text` (default), `json`, `sarif` (code scanning), `markdown` (PR comment). |
|
|
43
|
+
| `--json` | Deprecated alias for `--format json`. |
|
|
43
44
|
| `--fail-on <error\|warning>` | Non-zero exit threshold (default: `warning`). Ignored with `--baseline`. |
|
|
44
45
|
| `--no-fail` | Always exit 0 unless the scan itself failed. |
|
|
45
46
|
|
|
47
|
+
All formats keep the same exit codes, so `--format sarif` / `markdown` still gate a build.
|
|
48
|
+
|
|
46
49
|
### Exit codes
|
|
47
50
|
|
|
48
51
|
| Code | Meaning |
|
|
@@ -55,8 +58,8 @@ Apitella scan my-warehouse-mcp v1.4.0
|
|
|
55
58
|
|
|
56
59
|
Commit a snapshot of your server's surface, then fail a PR only when the change makes
|
|
57
60
|
it **worse** — a tool removed, a tool that lost its `readOnlyHint` / `destructiveHint`,
|
|
58
|
-
or a new error/warning. Pre-existing findings
|
|
59
|
-
the build.
|
|
61
|
+
a resource whose `mimeType` changed, or a new error/warning. Pre-existing findings
|
|
62
|
+
you've decided to live with don't fail the build.
|
|
60
63
|
|
|
61
64
|
```
|
|
62
65
|
# once, checked in:
|
|
@@ -93,15 +96,29 @@ the diff lands in the PR alongside the code.
|
|
|
93
96
|
npm ci && npm run build
|
|
94
97
|
node dist/server.js &
|
|
95
98
|
npx --yes wait-on http://localhost:3000/mcp
|
|
96
|
-
- uses: dodogeny/apitella-
|
|
99
|
+
- uses: dodogeny/apitella-scan-action@v1
|
|
97
100
|
with:
|
|
98
101
|
url: http://localhost:3000/mcp
|
|
99
102
|
baseline: .apitella/baseline.json
|
|
100
103
|
```
|
|
101
104
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
On `pull_request` events the action posts a **sticky PR comment** with the result and
|
|
106
|
+
baseline diff (needs `permissions: pull-requests: write`; `comment: false` to disable).
|
|
107
|
+
Other inputs: `url`/`input`, `baseline`, `fail-on`, `headers`, `format`, `output-file`,
|
|
108
|
+
`package-version`. Full example: [`examples/mcp-scan.yml`](./examples/mcp-scan.yml).
|
|
109
|
+
|
|
110
|
+
### SARIF → GitHub code scanning
|
|
111
|
+
|
|
112
|
+
```yaml
|
|
113
|
+
- uses: dodogeny/apitella-scan-action@v1
|
|
114
|
+
with:
|
|
115
|
+
url: http://localhost:3000/mcp
|
|
116
|
+
format: sarif
|
|
117
|
+
output-file: apitella.sarif
|
|
118
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
119
|
+
with:
|
|
120
|
+
sarif_file: apitella.sarif
|
|
121
|
+
```
|
|
105
122
|
|
|
106
123
|
### Plain npx
|
|
107
124
|
|
|
@@ -116,6 +133,8 @@ Action inputs: `url` **or** `input` (a `tools/list` dump), `baseline`, `fail-on`
|
|
|
116
133
|
"wipes…") but `destructiveHint` isn't `true`.
|
|
117
134
|
- **`readonly-hint-contradicted`** — `readOnlyHint: true` on a tool that takes a
|
|
118
135
|
`content` / `body` / `payload` / `data` parameter.
|
|
136
|
+
- **`openworld-hint-contradicted`** — `openWorldHint: false` on a tool whose description
|
|
137
|
+
says it reaches arbitrary external URLs or the open web.
|
|
119
138
|
- **`instruction-injection`** — tool or parameter text contains instruction-override
|
|
120
139
|
phrasing or invisible characters aimed at the model reading it.
|
|
121
140
|
- **`credential-exposed`** — an AWS key, GitHub/Slack token, or private-key block in
|
package/dist/analyze.js
CHANGED
|
@@ -23,6 +23,11 @@ const INJECTION_PATTERNS = [
|
|
|
23
23
|
/[]/,
|
|
24
24
|
];
|
|
25
25
|
const DESTRUCTIVE_VERBS = /\b(delete|deletes|deleted|deleting|remove|removes|removed|removing|drop|drops|dropped|dropping|wipe|wipes|wiped|wiping|purge|purges|purged|purging|destroy|destroys|destroyed|destroying|erase|erases|erased|erasing)\b/i;
|
|
26
|
+
// Phrasing that only makes sense if the tool reaches an "open world" of external entities —
|
|
27
|
+
// arbitrary/caller-supplied URLs, the public web. Deliberately narrow, same reasoning as
|
|
28
|
+
// DESTRUCTIVE_VERBS: it only ever fires alongside an explicit openWorldHint: false, so a
|
|
29
|
+
// false positive needs both a misleading description AND a wrong hint.
|
|
30
|
+
const OPEN_WORLD_PHRASES = /\b(arbitrary|any|external|remote|user[- ]?(?:provided|supplied|specified)|third[- ]?party)\s+(?:url|uri|endpoint|host|website|site|web\s?page|address)\b|\bthe (?:web|internet)\b|\bfetch(?:es|ing)?\s+(?:a\s+)?(?:url|link|web\s?page|remote\s+\w+)\b/i;
|
|
26
31
|
// Exact param-name match — catch a payload param named exactly "content" without
|
|
27
32
|
// false-positiving on "contentType" or "dataSource".
|
|
28
33
|
const WRITE_SHAPED_PARAM_NAMES = new Set([
|
|
@@ -111,6 +116,16 @@ export function analyze(schema, url) {
|
|
|
111
116
|
message: "Neither readOnlyHint nor destructiveHint is declared — an agent can't tell whether this is safe to auto-approve.",
|
|
112
117
|
});
|
|
113
118
|
}
|
|
119
|
+
if (op.annotations?.openWorldHint === false &&
|
|
120
|
+
op.description &&
|
|
121
|
+
OPEN_WORLD_PHRASES.test(op.description)) {
|
|
122
|
+
findings.push({
|
|
123
|
+
severity: "warning",
|
|
124
|
+
rule: "openworld-hint-contradicted",
|
|
125
|
+
operation: op.id,
|
|
126
|
+
message: "Declared openWorldHint: false, but the description says it reaches arbitrary external URLs or the open web — that's open-world behaviour a host would sandbox differently.",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
114
129
|
}
|
|
115
130
|
findings.push({
|
|
116
131
|
severity: "note",
|
package/dist/baseline.js
CHANGED
|
Binary file
|
package/dist/index.js
CHANGED
|
@@ -3,9 +3,11 @@ import { parseArgs } from "node:util";
|
|
|
3
3
|
import { analyze } from "./analyze.js";
|
|
4
4
|
import { diffSnapshots, makeSnapshot, readSnapshot, writeSnapshot, } from "./baseline.js";
|
|
5
5
|
import { fetchFromFile, fetchFromUrl } from "./fetch.js";
|
|
6
|
-
import { renderBaselineDiff, renderJson, renderText } from "./report.js";
|
|
7
|
-
|
|
6
|
+
import { renderBaselineDiff, renderJson, renderMarkdown, renderText, } from "./report.js";
|
|
7
|
+
import { renderSarif } from "./sarif.js";
|
|
8
|
+
const VERSION = "0.2.0";
|
|
8
9
|
const DEFAULT_BASELINE = ".apitella/baseline.json";
|
|
10
|
+
const FORMATS = ["text", "json", "sarif", "markdown"];
|
|
9
11
|
const HELP = `
|
|
10
12
|
apitella-scan — check an MCP server's tool surface for safety-annotation gaps
|
|
11
13
|
and prompt-injection risks.
|
|
@@ -27,10 +29,14 @@ OPTIONS
|
|
|
27
29
|
--baseline [file] Compare the scan to a committed snapshot and report what
|
|
28
30
|
changed (default file: ${DEFAULT_BASELINE}). Exits non-zero
|
|
29
31
|
only on a regression — a removed tool, a tool that lost its
|
|
30
|
-
safety hint, or a new
|
|
32
|
+
safety hint, a resource whose mimeType changed, or a new
|
|
33
|
+
error/warning.
|
|
31
34
|
--update-baseline Write the current surface to the baseline file and exit 0.
|
|
32
35
|
Run this to accept the current state as the new reference.
|
|
33
|
-
--
|
|
36
|
+
--format <fmt> Output format: text (default), json, sarif, markdown.
|
|
37
|
+
sarif is for GitHub code scanning; markdown is for a PR
|
|
38
|
+
comment. All formats keep the same exit codes.
|
|
39
|
+
--json Deprecated alias for --format json.
|
|
34
40
|
--fail-on <lvl> Without --baseline: exit non-zero when findings reach this
|
|
35
41
|
level, "error" or "warning" (default: warning).
|
|
36
42
|
--no-fail Always exit 0 unless the scan itself failed.
|
|
@@ -70,6 +76,7 @@ async function main() {
|
|
|
70
76
|
token: { type: "string" },
|
|
71
77
|
baseline: { type: "string" },
|
|
72
78
|
"update-baseline": { type: "boolean", default: false },
|
|
79
|
+
format: { type: "string" },
|
|
73
80
|
json: { type: "boolean", default: false },
|
|
74
81
|
"fail-on": { type: "string", default: "warning" },
|
|
75
82
|
"no-fail": { type: "boolean", default: false },
|
|
@@ -94,6 +101,15 @@ async function main() {
|
|
|
94
101
|
if (failOn !== "error" && failOn !== "warning") {
|
|
95
102
|
throw new UsageError(`--fail-on must be "error" or "warning", got "${failOn}".`);
|
|
96
103
|
}
|
|
104
|
+
let format = "text";
|
|
105
|
+
if (values.json)
|
|
106
|
+
format = "json";
|
|
107
|
+
if (values.format !== undefined) {
|
|
108
|
+
if (!FORMATS.includes(values.format)) {
|
|
109
|
+
throw new UsageError(`--format must be one of ${FORMATS.join(", ")}, got "${values.format}".`);
|
|
110
|
+
}
|
|
111
|
+
format = values.format;
|
|
112
|
+
}
|
|
97
113
|
const url = positionals[0];
|
|
98
114
|
if (!url && !values.input) {
|
|
99
115
|
throw new UsageError("Pass an MCP server URL or --input <file>. See --help.");
|
|
@@ -137,14 +153,21 @@ async function main() {
|
|
|
137
153
|
findings: report.findings,
|
|
138
154
|
})
|
|
139
155
|
: null;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
156
|
+
switch (format) {
|
|
157
|
+
case "json":
|
|
158
|
+
process.stdout.write(renderJson(report, target, meta, diff));
|
|
159
|
+
break;
|
|
160
|
+
case "sarif":
|
|
161
|
+
process.stdout.write(renderSarif(report, target, meta, VERSION));
|
|
162
|
+
break;
|
|
163
|
+
case "markdown":
|
|
164
|
+
process.stdout.write(renderMarkdown(report, target, meta, diff));
|
|
165
|
+
break;
|
|
166
|
+
default:
|
|
167
|
+
process.stdout.write(renderText(report, target, meta));
|
|
168
|
+
if (diff && baselinePath) {
|
|
169
|
+
process.stdout.write(renderBaselineDiff(diff, baselinePath));
|
|
170
|
+
}
|
|
148
171
|
}
|
|
149
172
|
if (values["no-fail"])
|
|
150
173
|
return 0;
|
package/dist/report.js
CHANGED
|
@@ -20,6 +20,11 @@ const LABEL = {
|
|
|
20
20
|
warning: c.yellow,
|
|
21
21
|
note: c.dim,
|
|
22
22
|
};
|
|
23
|
+
function serverLabel(target, meta) {
|
|
24
|
+
return meta?.serverName
|
|
25
|
+
? `${meta.serverName}${meta.serverVersion ? ` v${meta.serverVersion}` : ""}`
|
|
26
|
+
: target;
|
|
27
|
+
}
|
|
23
28
|
function countsLine(report) {
|
|
24
29
|
const parts = [
|
|
25
30
|
`${report.toolCount} tool${report.toolCount === 1 ? "" : "s"}`,
|
|
@@ -35,13 +40,18 @@ function countsLine(report) {
|
|
|
35
40
|
}
|
|
36
41
|
return parts.join(" · ");
|
|
37
42
|
}
|
|
43
|
+
function baselineIsEmpty(diff) {
|
|
44
|
+
return (diff.addedOps.length === 0 &&
|
|
45
|
+
diff.removedOps.length === 0 &&
|
|
46
|
+
diff.lostSafetyHint.length === 0 &&
|
|
47
|
+
diff.mimeTypeChanges.length === 0 &&
|
|
48
|
+
diff.newFindings.length === 0 &&
|
|
49
|
+
diff.resolvedFindings.length === 0);
|
|
50
|
+
}
|
|
38
51
|
export function renderText(report, target, meta) {
|
|
39
52
|
const lines = [];
|
|
40
|
-
const server = meta?.serverName
|
|
41
|
-
? `${meta.serverName}${meta.serverVersion ? ` v${meta.serverVersion}` : ""}`
|
|
42
|
-
: target;
|
|
43
53
|
lines.push("");
|
|
44
|
-
lines.push(`${c.bold("Apitella scan")} ${c.dim(
|
|
54
|
+
lines.push(`${c.bold("Apitella scan")} ${c.dim(serverLabel(target, meta))}`);
|
|
45
55
|
lines.push(c.dim(countsLine(report)));
|
|
46
56
|
lines.push("");
|
|
47
57
|
const ordered = [
|
|
@@ -74,12 +84,7 @@ export function renderText(report, target, meta) {
|
|
|
74
84
|
export function renderBaselineDiff(diff, baselinePath) {
|
|
75
85
|
const lines = [];
|
|
76
86
|
lines.push(`${c.bold("vs. baseline")} ${c.dim(baselinePath)}`);
|
|
77
|
-
|
|
78
|
-
diff.removedOps.length === 0 &&
|
|
79
|
-
diff.lostSafetyHint.length === 0 &&
|
|
80
|
-
diff.newFindings.length === 0 &&
|
|
81
|
-
diff.resolvedFindings.length === 0;
|
|
82
|
-
if (nothing) {
|
|
87
|
+
if (baselineIsEmpty(diff)) {
|
|
83
88
|
lines.push(c.green(" ✔ No change to the surface since the baseline."));
|
|
84
89
|
lines.push("");
|
|
85
90
|
return lines.join("\n");
|
|
@@ -93,6 +98,9 @@ export function renderBaselineDiff(diff, baselinePath) {
|
|
|
93
98
|
for (const id of diff.lostSafetyHint) {
|
|
94
99
|
lines.push(` ${c.red("!")} ${c.cyan(id)} lost its safety hint — it declared readOnlyHint/destructiveHint in the baseline and now declares neither.`);
|
|
95
100
|
}
|
|
101
|
+
for (const m of diff.mimeTypeChanges) {
|
|
102
|
+
lines.push(` ${c.red("~")} ${c.cyan(m.id)} mimeType ${m.from ?? "(none)"} → ${m.to ?? "(none)"} — a client parsing the old type will misread the new one.`);
|
|
103
|
+
}
|
|
96
104
|
for (const f of diff.newFindings) {
|
|
97
105
|
const scope = f.operation ? c.cyan(f.operation) : c.dim("server");
|
|
98
106
|
lines.push(` ${MARK[f.severity]} new ${scope} ${LABEL[f.severity](`[${f.rule}]`)}`);
|
|
@@ -110,7 +118,7 @@ export function renderBaselineDiff(diff, baselinePath) {
|
|
|
110
118
|
return lines.join("\n");
|
|
111
119
|
}
|
|
112
120
|
export function renderJson(report, target, meta, diff) {
|
|
113
|
-
return JSON.stringify({
|
|
121
|
+
return `${JSON.stringify({
|
|
114
122
|
target,
|
|
115
123
|
server: meta ?? null,
|
|
116
124
|
summary: {
|
|
@@ -122,5 +130,53 @@ export function renderJson(report, target, meta, diff) {
|
|
|
122
130
|
},
|
|
123
131
|
findings: report.findings,
|
|
124
132
|
baseline: diff ?? null,
|
|
125
|
-
}, null, 2)
|
|
133
|
+
}, null, 2)}\n`;
|
|
134
|
+
}
|
|
135
|
+
// A PR-comment-ready block. The leading HTML comment is a stable marker so a CI step can
|
|
136
|
+
// find and update its own comment in place instead of piling on new ones.
|
|
137
|
+
export const MARKDOWN_MARKER = "<!-- apitella-scan -->";
|
|
138
|
+
export function renderMarkdown(report, target, meta, diff) {
|
|
139
|
+
const out = [MARKDOWN_MARKER];
|
|
140
|
+
const { error, warning } = report.counts;
|
|
141
|
+
const verdict = error > 0
|
|
142
|
+
? `❌ **${error} error${error === 1 ? "" : "s"}**${warning ? `, ${warning} warning${warning === 1 ? "" : "s"}` : ""}`
|
|
143
|
+
: warning > 0
|
|
144
|
+
? `⚠️ **${warning} warning${warning === 1 ? "" : "s"}**`
|
|
145
|
+
: "✅ **Nothing flagged**";
|
|
146
|
+
out.push(`### Apitella MCP scan — \`${serverLabel(target, meta)}\``);
|
|
147
|
+
out.push("");
|
|
148
|
+
out.push(`${countsLine(report)} · ${verdict}`);
|
|
149
|
+
const flagged = report.findings.filter((f) => f.severity !== "note");
|
|
150
|
+
if (flagged.length > 0) {
|
|
151
|
+
out.push("");
|
|
152
|
+
out.push("| | Operation | Rule | Detail |");
|
|
153
|
+
out.push("|---|---|---|---|");
|
|
154
|
+
for (const f of flagged) {
|
|
155
|
+
const icon = f.severity === "error" ? "❌" : "⚠️";
|
|
156
|
+
out.push(`| ${icon} | \`${f.operation ?? "server"}\` | \`${f.rule}\` | ${f.message.replace(/\|/g, "\\|")} |`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (diff && !baselineIsEmpty(diff)) {
|
|
160
|
+
out.push("");
|
|
161
|
+
out.push(diff.regressed
|
|
162
|
+
? "#### 🔻 This change makes the MCP surface worse than the baseline"
|
|
163
|
+
: "#### Changes vs. baseline (no regressions)");
|
|
164
|
+
const bullets = [];
|
|
165
|
+
for (const id of diff.removedOps)
|
|
166
|
+
bullets.push(`\`−\` removed \`${id}\``);
|
|
167
|
+
for (const id of diff.addedOps)
|
|
168
|
+
bullets.push(`\`+\` added \`${id}\``);
|
|
169
|
+
for (const id of diff.lostSafetyHint)
|
|
170
|
+
bullets.push(`\`!\` \`${id}\` lost its safety hint`);
|
|
171
|
+
for (const m of diff.mimeTypeChanges)
|
|
172
|
+
bullets.push(`\`~\` \`${m.id}\` mimeType ${m.from ?? "(none)"} → ${m.to ?? "(none)"}`);
|
|
173
|
+
for (const f of diff.newFindings)
|
|
174
|
+
bullets.push(`${f.severity === "error" ? "❌" : "⚠️"} new \`${f.rule}\` on \`${f.operation ?? "server"}\``);
|
|
175
|
+
for (const f of diff.resolvedFindings)
|
|
176
|
+
bullets.push(`✅ resolved \`${f.rule}\` on \`${f.operation ?? "server"}\``);
|
|
177
|
+
out.push(...bullets.map((b) => `- ${b}`));
|
|
178
|
+
}
|
|
179
|
+
out.push("");
|
|
180
|
+
out.push("<sub>[Apitella MCP scan](https://apitella.com/guides/scanning-mcp-servers-in-ci) · point-in-time; continuous monitoring is the hosted product.</sub>");
|
|
181
|
+
return `${out.join("\n")}\n`;
|
|
126
182
|
}
|
package/dist/sarif.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// SARIF 2.1.0 output for GitHub code scanning (upload-sarif). MCP findings have no source
|
|
2
|
+
// file, so results are grouped under a path derived from the target — code scanning shows
|
|
3
|
+
// them there rather than "not associated with a file".
|
|
4
|
+
const HELP_URI = "https://apitella.com/guides/scanning-mcp-servers-in-ci";
|
|
5
|
+
const INFO_URI = "https://apitella.com/scan";
|
|
6
|
+
const SARIF_LEVEL = {
|
|
7
|
+
error: "error",
|
|
8
|
+
warning: "warning",
|
|
9
|
+
note: "note",
|
|
10
|
+
};
|
|
11
|
+
const RULE_DESCRIPTIONS = {
|
|
12
|
+
"no-safety-hints": "Tool declares neither readOnlyHint nor destructiveHint.",
|
|
13
|
+
"destructive-hint-mismatch": "Description reads as destructive but destructiveHint is not true.",
|
|
14
|
+
"readonly-hint-contradicted": "readOnlyHint is true on a tool that takes a write-shaped parameter.",
|
|
15
|
+
"openworld-hint-contradicted": "openWorldHint is false but the description implies open-web access.",
|
|
16
|
+
"instruction-injection": "Tool or parameter text reads as an instruction override, or hides characters.",
|
|
17
|
+
"credential-exposed": "A credential-shaped string appears in tool text.",
|
|
18
|
+
"insecure-transport": "The server URL is plain HTTP.",
|
|
19
|
+
"no-version-signal": "The server exposes no version or changelog signal.",
|
|
20
|
+
};
|
|
21
|
+
function uriForTarget(target) {
|
|
22
|
+
try {
|
|
23
|
+
const u = new URL(target);
|
|
24
|
+
return `${u.host}${u.pathname}`.replace(/\/+$/, "") || u.host;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Already a file path (input mode) — keep it relative, forward slashes.
|
|
28
|
+
return target.replace(/^\.?\//, "").replace(/\\/g, "/");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const toCamel = (kebab) => kebab.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase());
|
|
32
|
+
export function renderSarif(report, target, meta, version) {
|
|
33
|
+
const uri = uriForTarget(target);
|
|
34
|
+
const levelByRule = new Map();
|
|
35
|
+
for (const f of report.findings) {
|
|
36
|
+
const worse = !levelByRule.has(f.rule) ||
|
|
37
|
+
f.severity === "error" ||
|
|
38
|
+
(f.severity === "warning" && levelByRule.get(f.rule) === "note");
|
|
39
|
+
if (worse)
|
|
40
|
+
levelByRule.set(f.rule, f.severity);
|
|
41
|
+
}
|
|
42
|
+
const rules = [...levelByRule.keys()].sort().map((id) => ({
|
|
43
|
+
id,
|
|
44
|
+
name: toCamel(id),
|
|
45
|
+
shortDescription: { text: RULE_DESCRIPTIONS[id] ?? id },
|
|
46
|
+
helpUri: HELP_URI,
|
|
47
|
+
defaultConfiguration: { level: SARIF_LEVEL[levelByRule.get(id)] },
|
|
48
|
+
}));
|
|
49
|
+
const results = report.findings.map((f) => ({
|
|
50
|
+
ruleId: f.rule,
|
|
51
|
+
level: SARIF_LEVEL[f.severity],
|
|
52
|
+
message: { text: f.operation ? `${f.operation}: ${f.message}` : f.message },
|
|
53
|
+
locations: [
|
|
54
|
+
{
|
|
55
|
+
physicalLocation: {
|
|
56
|
+
artifactLocation: { uri },
|
|
57
|
+
region: { startLine: 1 },
|
|
58
|
+
},
|
|
59
|
+
...(f.operation
|
|
60
|
+
? { logicalLocations: [{ name: f.operation, kind: "function" }] }
|
|
61
|
+
: {}),
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
partialFingerprints: {
|
|
65
|
+
"apitellaScan/v1": `${f.rule}:${f.operation ?? "server"}`,
|
|
66
|
+
},
|
|
67
|
+
properties: { operation: f.operation, mcpTarget: target },
|
|
68
|
+
}));
|
|
69
|
+
return `${JSON.stringify({
|
|
70
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
71
|
+
version: "2.1.0",
|
|
72
|
+
runs: [
|
|
73
|
+
{
|
|
74
|
+
tool: {
|
|
75
|
+
driver: {
|
|
76
|
+
name: "apitella-scan",
|
|
77
|
+
informationUri: INFO_URI,
|
|
78
|
+
version,
|
|
79
|
+
rules,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
results,
|
|
83
|
+
...(meta?.serverName
|
|
84
|
+
? {
|
|
85
|
+
properties: {
|
|
86
|
+
mcpServer: meta.serverName,
|
|
87
|
+
mcpServerVersion: meta.serverVersion,
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
: {}),
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
}, null, 2)}\n`;
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apitella/scan",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Check an MCP server's tool surface for safety-annotation gaps and prompt-injection risks — at build time, in CI, or against a live URL.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "tsc -p tsconfig.json",
|
|
18
18
|
"dev": "tsx src/index.ts",
|
|
19
|
-
"test": "node --test --import tsx test/analyze.test.ts test/baseline.test.ts",
|
|
19
|
+
"test": "node --test --import tsx test/analyze.test.ts test/baseline.test.ts test/output.test.ts",
|
|
20
20
|
"prepublishOnly": "npm run build"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|