@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 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
- | `--json` | Machine-readable output (includes the baseline diff when `--baseline` is set). |
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 you've decided to live with don't fail
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-saas/cli@v1
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
- Action inputs: `url` **or** `input` (a `tools/list` dump), `baseline`, `fail-on`,
103
- `headers` (newline-separated `Name: value`), `package-version`. A full example is in
104
- [`examples/mcp-scan.yml`](./examples/mcp-scan.yml).
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
- const VERSION = "0.1.0";
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 error/warning.
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
- --json Emit JSON instead of the formatted report.
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
- if (values.json) {
141
- process.stdout.write(`${renderJson(report, target, meta, diff)}\n`);
142
- }
143
- else {
144
- process.stdout.write(renderText(report, target, meta));
145
- if (diff && baselinePath) {
146
- process.stdout.write(renderBaselineDiff(diff, baselinePath));
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(server)}`);
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
- const nothing = diff.addedOps.length === 0 &&
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.1.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": [