codex-plugin-doctor 1.25.0 → 1.27.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 +24 -13
- package/dist/core/doctor-config.d.ts +4 -1
- package/dist/core/doctor-config.js +118 -5
- package/dist/core/runtime-probe.js +78 -35
- package/dist/core/validate-plugin.js +59 -17
- package/dist/domain/types.d.ts +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp/generic-mcp-doctor.js +20 -12
- package/dist/reporting/render-json-report.js +7 -1
- package/dist/reporting/render-markdown-report.js +35 -15
- package/dist/reporting/render-sarif-report.js +9 -1
- package/dist/reporting/render-text-report.js +14 -1
- package/dist/rules/rule-catalog.d.ts +1 -1
- package/dist/rules/rule-catalog.js +18 -0
- package/dist/security/security-audit.js +1 -1
- package/dist/security/trust-score.js +29 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -314,7 +314,7 @@ codex-plugin-doctor check . --json --runtime --verbose-runtime
|
|
|
314
314
|
|
|
315
315
|
`init [path] --template ...` creates targeted starter packages. `skill-only` is the default minimal skill package, `mcp-stdio` adds a local stdio MCP config and mock server, `mcp-http` scaffolds a streamable HTTP MCP config, and `full-runtime` generates a stdio sample that passes the runtime protocol probes.
|
|
316
316
|
|
|
317
|
-
`security <path>` renders a focused package security scorecard. It reuses the existing package security findings, then adds deeper MCP command-surface checks for shell wrappers, encoded shell payloads, remote pipe-to-shell startup patterns, `cwd` values outside the plugin root, and plain HTTP URLs.
|
|
317
|
+
`security <path>` renders a focused package security scorecard. It reuses the existing package security findings, then adds deeper MCP command-surface checks for shell wrappers, encoded shell payloads, remote pipe-to-shell startup patterns, `cwd` values outside the plugin root, and plain HTTP URLs. Findings include structured, instance-specific evidence such as manifest fields, skill paths, server names, protocol methods, dependency sections, env keys, and bounded payload lengths. Deterministic SHA-256 fingerprints derive from the rule ID and canonical evidence, with package paths normalized for cross-machine stability. Secrets, prompt bodies, resource contents, tool payloads, stderr, and encoded commands are not copied into evidence. Use `--json` for automation or `--scorecard` for a compact status view.
|
|
318
318
|
|
|
319
319
|
`compat --client claude-desktop` checks whether the MCP package can be added to the local Claude Desktop setup. On Windows it looks for `%APPDATA%\Claude\claude_desktop_config.json`; on macOS it looks for `~/Library/Application Support/Claude/claude_desktop_config.json`. A valid existing config returns `PASS`, a missing Claude Desktop install returns `WARN`, and a malformed local config returns `FAIL` so you do not add new servers into a broken config file. If the package server name already exists in Claude Desktop, the command returns `WARN` with the duplicate server name. Add `--install-preview` to print the JSON snippet that should be merged into `claude_desktop_config.json`; it does not modify files. Use `--apply --backup` only when you want the CLI to create a timestamped backup and merge the server config. Apply mode refuses to overwrite duplicate server names.
|
|
320
320
|
|
|
@@ -336,16 +336,27 @@ codex-plugin-doctor check . --json --runtime --verbose-runtime
|
|
|
336
336
|
|
|
337
337
|
`fix --dry-run` renders safe automatic fix plans without changing files. `fix --interactive --backup` shows the same numbered plan, then applies everything after `yes` or only selected action numbers such as `1,3`. `fix --apply --backup` applies supported safe fixes, such as manifest defaults and missing skills directories, after creating backups.
|
|
338
338
|
|
|
339
|
-
Optional local policy file:
|
|
340
|
-
|
|
341
|
-
```json
|
|
342
|
-
{
|
|
343
|
-
"ignoreRules": ["plugin.heuristic.description.too_long"],
|
|
344
|
-
"
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
339
|
+
Optional local policy file:
|
|
340
|
+
|
|
341
|
+
```json
|
|
342
|
+
{
|
|
343
|
+
"ignoreRules": ["plugin.heuristic.description.too_long"],
|
|
344
|
+
"suppressions": [
|
|
345
|
+
{
|
|
346
|
+
"fingerprint": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
347
|
+
"reason": "Accepted until the upstream package layout is corrected.",
|
|
348
|
+
"expiresAt": "2026-07-31"
|
|
349
|
+
}
|
|
350
|
+
],
|
|
351
|
+
"failOnWarnings": true
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
`ignoreRules` disables every finding with a matching rule ID. Prefer `suppressions` when accepting one specific finding instance: each record requires the exact 64-character lowercase SHA-256 fingerprint shown in Doctor reports, a non-empty review reason, and a real `YYYY-MM-DD` expiration date.
|
|
356
|
+
|
|
357
|
+
Active suppressions remove only the matching finding from the active result and preserve it under `suppressedFindings` for audit output. Expired or invalid records do not suppress findings; Doctor emits `suppression.expired` or `suppression.invalid` warnings instead. Duplicate active records use the first valid match.
|
|
358
|
+
|
|
359
|
+
Run these when you want Codex Plugin Doctor to find plugins from the local Codex installation:
|
|
349
360
|
|
|
350
361
|
```bash
|
|
351
362
|
codex-plugin-doctor list --installed
|
|
@@ -370,9 +381,9 @@ jobs:
|
|
|
370
381
|
runs-on: ubuntu-latest
|
|
371
382
|
steps:
|
|
372
383
|
- uses: actions/checkout@v5
|
|
373
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
384
|
+
- uses: Esquetta/CodexPluginDoctor@v1.27.0
|
|
374
385
|
with:
|
|
375
|
-
version: "1.
|
|
386
|
+
version: "1.27.0"
|
|
376
387
|
path: .
|
|
377
388
|
runtime: "true"
|
|
378
389
|
policy: codex-publish
|
|
@@ -2,6 +2,9 @@ import type { CheckResult } from "../domain/types.js";
|
|
|
2
2
|
export interface DoctorConfig {
|
|
3
3
|
ignoreRules: string[];
|
|
4
4
|
failOnWarnings: boolean;
|
|
5
|
+
suppressions: unknown[];
|
|
5
6
|
}
|
|
6
7
|
export declare function loadDoctorConfig(targetPath: string, explicitConfigPath?: string | null): Promise<DoctorConfig>;
|
|
7
|
-
export declare function applyDoctorConfig(result: CheckResult, config: DoctorConfig
|
|
8
|
+
export declare function applyDoctorConfig(result: CheckResult, config: DoctorConfig, options?: {
|
|
9
|
+
now?: Date;
|
|
10
|
+
}): CheckResult;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { withFindingFingerprint } from "../reporting/finding-fingerprint.js";
|
|
3
4
|
const defaultConfig = {
|
|
4
5
|
ignoreRules: [],
|
|
5
|
-
failOnWarnings: false
|
|
6
|
+
failOnWarnings: false,
|
|
7
|
+
suppressions: []
|
|
6
8
|
};
|
|
7
9
|
function isStringArray(value) {
|
|
8
10
|
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
@@ -19,15 +21,69 @@ export async function loadDoctorConfig(targetPath, explicitConfigPath) {
|
|
|
19
21
|
: defaultConfig.ignoreRules,
|
|
20
22
|
failOnWarnings: typeof parsed.failOnWarnings === "boolean"
|
|
21
23
|
? parsed.failOnWarnings
|
|
22
|
-
: defaultConfig.failOnWarnings
|
|
24
|
+
: defaultConfig.failOnWarnings,
|
|
25
|
+
suppressions: Array.isArray(parsed.suppressions)
|
|
26
|
+
? parsed.suppressions
|
|
27
|
+
: defaultConfig.suppressions
|
|
23
28
|
};
|
|
24
29
|
}
|
|
25
30
|
catch {
|
|
26
31
|
return defaultConfig;
|
|
27
32
|
}
|
|
28
33
|
}
|
|
29
|
-
export function applyDoctorConfig(result, config) {
|
|
30
|
-
const
|
|
34
|
+
export function applyDoctorConfig(result, config, options = {}) {
|
|
35
|
+
const configuredFindings = result.findings.filter((finding) => !config.ignoreRules.includes(finding.id));
|
|
36
|
+
const now = options.now ?? new Date();
|
|
37
|
+
const activeSuppressions = new Map();
|
|
38
|
+
const governanceWarnings = [];
|
|
39
|
+
const summary = {
|
|
40
|
+
applied: 0,
|
|
41
|
+
expired: 0,
|
|
42
|
+
invalid: 0
|
|
43
|
+
};
|
|
44
|
+
for (const [index, suppression] of config.suppressions.entries()) {
|
|
45
|
+
const validation = validateSuppression(suppression);
|
|
46
|
+
if (!validation.valid) {
|
|
47
|
+
summary.invalid += 1;
|
|
48
|
+
governanceWarnings.push(buildGovernanceWarning(result.targetPath, "suppression.invalid", "A targeted suppression record is invalid and was not applied.", "Invalid suppression records can create false confidence because they do not match or expire as intended.", "Fix or remove the invalid suppression record in `.codex-doctor.json`.", {
|
|
49
|
+
suppressionIndex: index,
|
|
50
|
+
field: validation.field
|
|
51
|
+
}));
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (isExpired(validation.expiresAt, now)) {
|
|
55
|
+
summary.expired += 1;
|
|
56
|
+
governanceWarnings.push(buildGovernanceWarning(result.targetPath, "suppression.expired", "A targeted suppression has expired and was not applied.", "Expired risk acceptance must be reviewed again before the finding can be suppressed.", "Remove the expired suppression or replace it with a newly reviewed expiration date.", {
|
|
57
|
+
suppressionIndex: index,
|
|
58
|
+
fingerprint: validation.fingerprint,
|
|
59
|
+
expiresAt: validation.expiresAt
|
|
60
|
+
}));
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!activeSuppressions.has(validation.fingerprint)) {
|
|
64
|
+
activeSuppressions.set(validation.fingerprint, {
|
|
65
|
+
reason: validation.reason,
|
|
66
|
+
expiresAt: validation.expiresAt
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const findings = [];
|
|
71
|
+
const suppressedFindings = [];
|
|
72
|
+
for (const finding of configuredFindings) {
|
|
73
|
+
const suppression = finding.fingerprint
|
|
74
|
+
? activeSuppressions.get(finding.fingerprint)
|
|
75
|
+
: undefined;
|
|
76
|
+
if (!suppression) {
|
|
77
|
+
findings.push(finding);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
summary.applied += 1;
|
|
81
|
+
suppressedFindings.push({
|
|
82
|
+
...finding,
|
|
83
|
+
suppression
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
findings.push(...governanceWarnings);
|
|
31
87
|
const hasFailures = findings.some((finding) => finding.severity === "fail");
|
|
32
88
|
const hasWarnings = findings.some((finding) => finding.severity === "warn");
|
|
33
89
|
const warningFailure = config.failOnWarnings && hasWarnings;
|
|
@@ -40,6 +96,63 @@ export function applyDoctorConfig(result, config) {
|
|
|
40
96
|
...result,
|
|
41
97
|
status,
|
|
42
98
|
exitCode: status === "fail" ? 1 : 0,
|
|
43
|
-
findings
|
|
99
|
+
findings,
|
|
100
|
+
...(config.suppressions.length > 0
|
|
101
|
+
? {
|
|
102
|
+
suppressedFindings,
|
|
103
|
+
suppressionSummary: summary
|
|
104
|
+
}
|
|
105
|
+
: {})
|
|
44
106
|
};
|
|
45
107
|
}
|
|
108
|
+
function isPlainObject(value) {
|
|
109
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
110
|
+
}
|
|
111
|
+
function isRealDate(value) {
|
|
112
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
113
|
+
if (!match) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
const year = Number(match[1]);
|
|
117
|
+
const month = Number(match[2]);
|
|
118
|
+
const day = Number(match[3]);
|
|
119
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
120
|
+
return (date.getUTCFullYear() === year &&
|
|
121
|
+
date.getUTCMonth() === month - 1 &&
|
|
122
|
+
date.getUTCDate() === day);
|
|
123
|
+
}
|
|
124
|
+
function validateSuppression(value) {
|
|
125
|
+
if (!isPlainObject(value)) {
|
|
126
|
+
return { valid: false, field: "record" };
|
|
127
|
+
}
|
|
128
|
+
if (typeof value.fingerprint !== "string" ||
|
|
129
|
+
!/^[a-f0-9]{64}$/.test(value.fingerprint)) {
|
|
130
|
+
return { valid: false, field: "fingerprint" };
|
|
131
|
+
}
|
|
132
|
+
if (typeof value.reason !== "string" || value.reason.trim().length === 0) {
|
|
133
|
+
return { valid: false, field: "reason" };
|
|
134
|
+
}
|
|
135
|
+
if (typeof value.expiresAt !== "string" || !isRealDate(value.expiresAt)) {
|
|
136
|
+
return { valid: false, field: "expiresAt" };
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
valid: true,
|
|
140
|
+
fingerprint: value.fingerprint,
|
|
141
|
+
reason: value.reason.trim(),
|
|
142
|
+
expiresAt: value.expiresAt
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function isExpired(expiresAt, now) {
|
|
146
|
+
const expirationBoundary = Date.parse(`${expiresAt}T00:00:00.000Z`) + 86_400_000;
|
|
147
|
+
return now.getTime() >= expirationBoundary;
|
|
148
|
+
}
|
|
149
|
+
function buildGovernanceWarning(targetPath, id, message, impact, suggestedFix, evidence) {
|
|
150
|
+
return withFindingFingerprint({
|
|
151
|
+
id,
|
|
152
|
+
severity: "warn",
|
|
153
|
+
message,
|
|
154
|
+
impact,
|
|
155
|
+
suggestedFix,
|
|
156
|
+
evidence
|
|
157
|
+
}, targetPath);
|
|
158
|
+
}
|
|
@@ -21,22 +21,55 @@ function createRuntimeScorecard() {
|
|
|
21
21
|
promptGet: "unsupported"
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
|
-
function buildFailure(id, message, impact, suggestedFix) {
|
|
24
|
+
function buildFailure(id, message, impact, suggestedFix, evidence) {
|
|
25
25
|
return {
|
|
26
26
|
id,
|
|
27
27
|
severity: "fail",
|
|
28
28
|
message,
|
|
29
29
|
impact,
|
|
30
|
-
suggestedFix
|
|
30
|
+
suggestedFix,
|
|
31
|
+
...(evidence ? { evidence } : {})
|
|
31
32
|
};
|
|
32
33
|
}
|
|
33
|
-
function buildWarning(id, message, impact, suggestedFix) {
|
|
34
|
+
function buildWarning(id, message, impact, suggestedFix, evidence) {
|
|
34
35
|
return {
|
|
35
36
|
id,
|
|
36
37
|
severity: "warn",
|
|
37
38
|
message,
|
|
38
39
|
impact,
|
|
39
|
-
suggestedFix
|
|
40
|
+
suggestedFix,
|
|
41
|
+
...(evidence ? { evidence } : {})
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function methodForRuntimeFinding(id) {
|
|
45
|
+
if (id.includes(".initialize."))
|
|
46
|
+
return "initialize";
|
|
47
|
+
if (id.includes(".tools_list."))
|
|
48
|
+
return "tools/list";
|
|
49
|
+
if (id.includes(".tool_call."))
|
|
50
|
+
return "tools/call";
|
|
51
|
+
if (id.includes(".resources_list."))
|
|
52
|
+
return "resources/list";
|
|
53
|
+
if (id.includes(".resource_read."))
|
|
54
|
+
return "resources/read";
|
|
55
|
+
if (id.includes(".resource_templates_list."))
|
|
56
|
+
return "resources/templates/list";
|
|
57
|
+
if (id.includes(".prompts_list."))
|
|
58
|
+
return "prompts/list";
|
|
59
|
+
if (id.includes(".prompt_get."))
|
|
60
|
+
return "prompts/get";
|
|
61
|
+
if (id.includes(".startup.") || id.includes(".exited_early"))
|
|
62
|
+
return "startup";
|
|
63
|
+
return "protocol";
|
|
64
|
+
}
|
|
65
|
+
function withRuntimeEvidence(finding, serverName) {
|
|
66
|
+
return {
|
|
67
|
+
...finding,
|
|
68
|
+
evidence: {
|
|
69
|
+
serverName,
|
|
70
|
+
method: methodForRuntimeFinding(finding.id),
|
|
71
|
+
...(finding.evidence ?? {})
|
|
72
|
+
}
|
|
40
73
|
};
|
|
41
74
|
}
|
|
42
75
|
function isPlainObject(value) {
|
|
@@ -511,55 +544,56 @@ function getContentBlockLength(content) {
|
|
|
511
544
|
}
|
|
512
545
|
return 0;
|
|
513
546
|
}
|
|
514
|
-
function collectOversizedToolCallWarnings(message) {
|
|
547
|
+
function collectOversizedToolCallWarnings(message, evidence) {
|
|
515
548
|
if (!isPlainObject(message.result) || !Array.isArray(message.result.content)) {
|
|
516
549
|
return [];
|
|
517
550
|
}
|
|
518
|
-
const
|
|
519
|
-
return isPlainObject(content)
|
|
520
|
-
|
|
521
|
-
|
|
551
|
+
const contentLength = message.result.content.reduce((maximum, content) => {
|
|
552
|
+
return isPlainObject(content)
|
|
553
|
+
? Math.max(maximum, getContentBlockLength(content))
|
|
554
|
+
: maximum;
|
|
555
|
+
}, 0);
|
|
556
|
+
if (contentLength <= MAX_TOOL_CALL_CONTENT_LENGTH) {
|
|
522
557
|
return [];
|
|
523
558
|
}
|
|
524
559
|
return [
|
|
525
|
-
buildWarning("plugin.runtime.tool_call.content_too_large", "The tools/call response contains unusually large content payloads.", "Large tool call payloads increase runtime cost, transcript size, and the risk of degraded model performance.", "Keep tool responses concise or move large payloads into resource references instead of inline content.")
|
|
560
|
+
buildWarning("plugin.runtime.tool_call.content_too_large", "The tools/call response contains unusually large content payloads.", "Large tool call payloads increase runtime cost, transcript size, and the risk of degraded model performance.", "Keep tool responses concise or move large payloads into resource references instead of inline content.", { ...evidence, contentLength })
|
|
526
561
|
];
|
|
527
562
|
}
|
|
528
|
-
function collectOversizedResourceReadWarnings(message) {
|
|
563
|
+
function collectOversizedResourceReadWarnings(message, evidence) {
|
|
529
564
|
if (!isPlainObject(message.result) || !Array.isArray(message.result.contents)) {
|
|
530
565
|
return [];
|
|
531
566
|
}
|
|
532
|
-
const
|
|
567
|
+
const contentLength = message.result.contents.reduce((maximum, content) => {
|
|
533
568
|
if (!isPlainObject(content)) {
|
|
534
|
-
return
|
|
569
|
+
return maximum;
|
|
535
570
|
}
|
|
536
571
|
const textLength = typeof content.text === "string" ? content.text.length : 0;
|
|
537
572
|
const blobLength = typeof content.blob === "string" ? content.blob.length : 0;
|
|
538
|
-
return (textLength
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
if (!hasOversizedContent) {
|
|
573
|
+
return Math.max(maximum, textLength, blobLength);
|
|
574
|
+
}, 0);
|
|
575
|
+
if (contentLength <= MAX_RESOURCE_READ_CONTENT_LENGTH) {
|
|
542
576
|
return [];
|
|
543
577
|
}
|
|
544
578
|
return [
|
|
545
|
-
buildWarning("plugin.runtime.resource_read.content_too_large", "The resources/read response contains unusually large resource payloads.", "Large resource contents increase runtime cost and can make downstream model use less reliable.", "Reduce inline resource size or break large resources into smaller references.")
|
|
579
|
+
buildWarning("plugin.runtime.resource_read.content_too_large", "The resources/read response contains unusually large resource payloads.", "Large resource contents increase runtime cost and can make downstream model use less reliable.", "Reduce inline resource size or break large resources into smaller references.", { ...evidence, contentLength })
|
|
546
580
|
];
|
|
547
581
|
}
|
|
548
|
-
function collectOversizedPromptGetWarnings(message) {
|
|
582
|
+
function collectOversizedPromptGetWarnings(message, evidence) {
|
|
549
583
|
if (!isPlainObject(message.result) || !Array.isArray(message.result.messages)) {
|
|
550
584
|
return [];
|
|
551
585
|
}
|
|
552
|
-
const
|
|
586
|
+
const contentLength = message.result.messages.reduce((maximum, promptMessage) => {
|
|
553
587
|
if (!isPlainObject(promptMessage) || !isPlainObject(promptMessage.content)) {
|
|
554
|
-
return
|
|
588
|
+
return maximum;
|
|
555
589
|
}
|
|
556
|
-
return getContentBlockLength(promptMessage.content)
|
|
557
|
-
});
|
|
558
|
-
if (
|
|
590
|
+
return Math.max(maximum, getContentBlockLength(promptMessage.content));
|
|
591
|
+
}, 0);
|
|
592
|
+
if (contentLength <= MAX_PROMPT_GET_CONTENT_LENGTH) {
|
|
559
593
|
return [];
|
|
560
594
|
}
|
|
561
595
|
return [
|
|
562
|
-
buildWarning("plugin.runtime.prompt_get.content_too_large", "The prompts/get response contains unusually large prompt message payloads.", "Large prompt bodies increase token cost and reduce the usefulness of prompt probing as a lightweight validation step.", "Keep prompt message content concise or move large context into resources.")
|
|
596
|
+
buildWarning("plugin.runtime.prompt_get.content_too_large", "The prompts/get response contains unusually large prompt message payloads.", "Large prompt bodies increase token cost and reduce the usefulness of prompt probing as a lightweight validation step.", "Keep prompt message content concise or move large context into resources.", { ...evidence, contentLength })
|
|
563
597
|
];
|
|
564
598
|
}
|
|
565
599
|
async function probeCommandServer(input) {
|
|
@@ -596,7 +630,10 @@ async function probeCommandServer(input) {
|
|
|
596
630
|
child.kill("SIGTERM");
|
|
597
631
|
}
|
|
598
632
|
resolve({
|
|
599
|
-
findings: [
|
|
633
|
+
findings: [
|
|
634
|
+
...warnings.map((warning) => withRuntimeEvidence(warning, serverName)),
|
|
635
|
+
...(finding ? [withRuntimeEvidence(finding, serverName)] : [])
|
|
636
|
+
],
|
|
600
637
|
scorecard
|
|
601
638
|
});
|
|
602
639
|
};
|
|
@@ -752,14 +789,16 @@ async function probeCommandServer(input) {
|
|
|
752
789
|
const toolCallResponse = await sendRequest("tools/call", {
|
|
753
790
|
name: callableTool.tool.name,
|
|
754
791
|
arguments: callableTool.args
|
|
755
|
-
}, buildFailure("plugin.runtime.tool_call.timeout", `The MCP server \`${serverName}\` did not answer the tools/call request in time.`, "A server that cannot complete a basic tool invocation in time will feel broken in real Codex usage.", "Inspect the selected tool implementation and reduce its response latency."));
|
|
792
|
+
}, buildFailure("plugin.runtime.tool_call.timeout", `The MCP server \`${serverName}\` did not answer the tools/call request in time.`, "A server that cannot complete a basic tool invocation in time will feel broken in real Codex usage.", "Inspect the selected tool implementation and reduce its response latency.", { toolName: callableTool.tool.name }));
|
|
756
793
|
if (isErrorResponse(toolCallResponse) || !isValidCallToolResult(toolCallResponse)) {
|
|
757
794
|
scorecard.toolsCall = "fail";
|
|
758
|
-
settle(buildFailure("plugin.runtime.tool_call.invalid", `The MCP server \`${serverName}\` returned an invalid tools/call result.`, "Codex cannot safely consume malformed tool call results from the server.", "Return a CallToolResult with a `content` array containing valid MCP content blocks."));
|
|
795
|
+
settle(buildFailure("plugin.runtime.tool_call.invalid", `The MCP server \`${serverName}\` returned an invalid tools/call result.`, "Codex cannot safely consume malformed tool call results from the server.", "Return a CallToolResult with a `content` array containing valid MCP content blocks.", { toolName: callableTool.tool.name }));
|
|
759
796
|
return;
|
|
760
797
|
}
|
|
761
798
|
scorecard.toolsCall = "pass";
|
|
762
|
-
warnings.push(...collectOversizedToolCallWarnings(toolCallResponse
|
|
799
|
+
warnings.push(...collectOversizedToolCallWarnings(toolCallResponse, {
|
|
800
|
+
toolName: callableTool.tool.name
|
|
801
|
+
}));
|
|
763
802
|
}
|
|
764
803
|
if (!hasResourcesCapability(initializeResponse)) {
|
|
765
804
|
scorecard.resourcesList = "unsupported";
|
|
@@ -781,15 +820,17 @@ async function probeCommandServer(input) {
|
|
|
781
820
|
if (resources.length > 0) {
|
|
782
821
|
const resourceReadResponse = await sendRequest("resources/read", {
|
|
783
822
|
uri: resources[0].uri
|
|
784
|
-
}, buildFailure("plugin.runtime.resource_read.timeout", `The MCP server \`${serverName}\` did not answer the resources/read request in time.`, "A server that lists resources but cannot read them in time will feel broken in Codex.", "Inspect the resource read path and reduce latency before returning resource contents."));
|
|
823
|
+
}, buildFailure("plugin.runtime.resource_read.timeout", `The MCP server \`${serverName}\` did not answer the resources/read request in time.`, "A server that lists resources but cannot read them in time will feel broken in Codex.", "Inspect the resource read path and reduce latency before returning resource contents.", { resourceUri: resources[0].uri }));
|
|
785
824
|
if (isErrorResponse(resourceReadResponse) ||
|
|
786
825
|
!isValidReadResourceResult(resourceReadResponse)) {
|
|
787
826
|
scorecard.resourceRead = "fail";
|
|
788
|
-
settle(buildFailure("plugin.runtime.resource_read.invalid", `The MCP server \`${serverName}\` returned an invalid resources/read result.`, "Codex cannot safely consume malformed resource contents from `resources/read`.", "Return a `contents` array where each entry has a string `uri` plus either string `text` or string `blob`."));
|
|
827
|
+
settle(buildFailure("plugin.runtime.resource_read.invalid", `The MCP server \`${serverName}\` returned an invalid resources/read result.`, "Codex cannot safely consume malformed resource contents from `resources/read`.", "Return a `contents` array where each entry has a string `uri` plus either string `text` or string `blob`.", { resourceUri: resources[0].uri }));
|
|
789
828
|
return;
|
|
790
829
|
}
|
|
791
830
|
scorecard.resourceRead = "pass";
|
|
792
|
-
warnings.push(...collectOversizedResourceReadWarnings(resourceReadResponse
|
|
831
|
+
warnings.push(...collectOversizedResourceReadWarnings(resourceReadResponse, {
|
|
832
|
+
resourceUri: resources[0].uri
|
|
833
|
+
}));
|
|
793
834
|
}
|
|
794
835
|
else {
|
|
795
836
|
scorecard.resourceRead = "skipped";
|
|
@@ -842,15 +883,17 @@ async function probeCommandServer(input) {
|
|
|
842
883
|
...(buildPromptArguments(promptForGet)
|
|
843
884
|
? { arguments: buildPromptArguments(promptForGet) }
|
|
844
885
|
: {})
|
|
845
|
-
}, buildFailure("plugin.runtime.prompt_get.timeout", `The MCP server \`${serverName}\` did not answer the prompts/get request in time.`, "A server that lists prompts but cannot return prompt content in time will feel broken in Codex.", "Inspect the prompt retrieval path and reduce latency before returning prompt messages."));
|
|
886
|
+
}, buildFailure("plugin.runtime.prompt_get.timeout", `The MCP server \`${serverName}\` did not answer the prompts/get request in time.`, "A server that lists prompts but cannot return prompt content in time will feel broken in Codex.", "Inspect the prompt retrieval path and reduce latency before returning prompt messages.", { promptName: promptForGet.name }));
|
|
846
887
|
if (isErrorResponse(promptGetResponse) ||
|
|
847
888
|
!isValidPromptGetResult(promptGetResponse)) {
|
|
848
889
|
scorecard.promptGet = "fail";
|
|
849
|
-
settle(buildFailure("plugin.runtime.prompt_get.invalid", `The MCP server \`${serverName}\` returned an invalid prompts/get result.`, "Codex cannot safely consume malformed prompt messages from `prompts/get`.", "Return a `messages` array where each entry has a valid `role` and a valid MCP content block."));
|
|
890
|
+
settle(buildFailure("plugin.runtime.prompt_get.invalid", `The MCP server \`${serverName}\` returned an invalid prompts/get result.`, "Codex cannot safely consume malformed prompt messages from `prompts/get`.", "Return a `messages` array where each entry has a valid `role` and a valid MCP content block.", { promptName: promptForGet.name }));
|
|
850
891
|
return;
|
|
851
892
|
}
|
|
852
893
|
scorecard.promptGet = "pass";
|
|
853
|
-
warnings.push(...collectOversizedPromptGetWarnings(promptGetResponse
|
|
894
|
+
warnings.push(...collectOversizedPromptGetWarnings(promptGetResponse, {
|
|
895
|
+
promptName: promptForGet.name
|
|
896
|
+
}));
|
|
854
897
|
}
|
|
855
898
|
}
|
|
856
899
|
settle(null);
|
|
@@ -193,19 +193,20 @@ function isDescriptionLikelyVerbose(description, mode) {
|
|
|
193
193
|
}
|
|
194
194
|
function validateRequiredManifestFields(discoveredPackage) {
|
|
195
195
|
const findings = [];
|
|
196
|
-
const { manifest } = discoveredPackage;
|
|
196
|
+
const { manifest, rootPath } = discoveredPackage;
|
|
197
|
+
const manifestPath = relativePackagePath(rootPath, discoveredPackage.manifestPath);
|
|
197
198
|
if (!manifest.name) {
|
|
198
|
-
findings.push(buildFailure("plugin.manifest.name.missing", "The plugin manifest is missing a `name` field.", "Codex cannot identify the plugin reliably without a stable package name.", "Add a kebab-case `name` field to `.codex-plugin/plugin.json`."));
|
|
199
|
+
findings.push(buildFailure("plugin.manifest.name.missing", "The plugin manifest is missing a `name` field.", "Codex cannot identify the plugin reliably without a stable package name.", "Add a kebab-case `name` field to `.codex-plugin/plugin.json`.", { manifestPath, field: "name" }));
|
|
199
200
|
}
|
|
200
201
|
if (!manifest.version) {
|
|
201
|
-
findings.push(buildFailure("plugin.manifest.version.missing", "The plugin manifest is missing a `version` field.", "Release and compatibility workflows cannot reason about the package version.", "Add a semantic `version` field to `.codex-plugin/plugin.json`."));
|
|
202
|
+
findings.push(buildFailure("plugin.manifest.version.missing", "The plugin manifest is missing a `version` field.", "Release and compatibility workflows cannot reason about the package version.", "Add a semantic `version` field to `.codex-plugin/plugin.json`.", { manifestPath, field: "version" }));
|
|
202
203
|
}
|
|
203
204
|
if (!manifest.description) {
|
|
204
|
-
findings.push(buildFailure("plugin.manifest.description.missing", "The plugin manifest is missing a `description` field.", "The package will be harder to understand and present in Codex surfaces.", "Add a concise `description` field to `.codex-plugin/plugin.json`."));
|
|
205
|
+
findings.push(buildFailure("plugin.manifest.description.missing", "The plugin manifest is missing a `description` field.", "The package will be harder to understand and present in Codex surfaces.", "Add a concise `description` field to `.codex-plugin/plugin.json`.", { manifestPath, field: "description" }));
|
|
205
206
|
}
|
|
206
207
|
if (manifest.description &&
|
|
207
208
|
isDescriptionLikelyVerbose(manifest.description, "plugin")) {
|
|
208
|
-
findings.push(buildWarning("plugin.heuristic.description.too_long", "The plugin manifest description is likely too verbose.", "Overly long metadata increases context cost and can dilute plugin discovery quality.", "Shorten the manifest description to a precise one- or two-sentence summary."));
|
|
209
|
+
findings.push(buildWarning("plugin.heuristic.description.too_long", "The plugin manifest description is likely too verbose.", "Overly long metadata increases context cost and can dilute plugin discovery quality.", "Shorten the manifest description to a precise one- or two-sentence summary.", { manifestPath, field: "description" }));
|
|
209
210
|
}
|
|
210
211
|
return findings;
|
|
211
212
|
}
|
|
@@ -230,7 +231,12 @@ async function validateSkillsDirectory(discoveredPackage) {
|
|
|
230
231
|
return [];
|
|
231
232
|
}
|
|
232
233
|
return [
|
|
233
|
-
buildFailure("plugin.skills.path.missing", "The plugin manifest points to a missing skills directory.", "Codex will not be able to load the packaged skills as expected.", `Create the skills directory at \`${skillsPath}\` or update the manifest path
|
|
234
|
+
buildFailure("plugin.skills.path.missing", "The plugin manifest points to a missing skills directory.", "Codex will not be able to load the packaged skills as expected.", `Create the skills directory at \`${skillsPath}\` or update the manifest path.`, {
|
|
235
|
+
manifestPath: relativePackagePath(rootPath, discoveredPackage.manifestPath),
|
|
236
|
+
field: "skills",
|
|
237
|
+
configuredPath: manifest.skills,
|
|
238
|
+
resolvedPath: skillsPath
|
|
239
|
+
})
|
|
234
240
|
];
|
|
235
241
|
}
|
|
236
242
|
async function validateSkillDefinitions(discoveredPackage) {
|
|
@@ -254,27 +260,47 @@ async function validateSkillDefinitions(discoveredPackage) {
|
|
|
254
260
|
const skillFilePath = path.join(skillRoot, "SKILL.md");
|
|
255
261
|
const skillFileExists = await fileExists(skillFilePath);
|
|
256
262
|
if (!skillFileExists) {
|
|
257
|
-
findings.push(buildFailure("plugin.skill.skill_md.missing", `The skill \`${skillDirectory.name}\` is missing \`SKILL.md\`.`, "Codex cannot load a skill directory that does not contain the required SKILL.md entrypoint.", `Add \`SKILL.md\` to \`${skillRoot}\` with at least \`name\` and \`description\` frontmatter
|
|
263
|
+
findings.push(buildFailure("plugin.skill.skill_md.missing", `The skill \`${skillDirectory.name}\` is missing \`SKILL.md\`.`, "Codex cannot load a skill directory that does not contain the required SKILL.md entrypoint.", `Add \`SKILL.md\` to \`${skillRoot}\` with at least \`name\` and \`description\` frontmatter.`, {
|
|
264
|
+
skillName: skillDirectory.name,
|
|
265
|
+
skillPath: relativePackagePath(rootPath, skillFilePath)
|
|
266
|
+
}));
|
|
258
267
|
continue;
|
|
259
268
|
}
|
|
260
269
|
const skillContent = await readFile(skillFilePath, "utf8");
|
|
261
270
|
const frontmatter = parseSkillFrontmatter(skillContent);
|
|
262
271
|
if (!frontmatter?.name) {
|
|
263
|
-
findings.push(buildFailure("plugin.skill.name.missing", `The skill \`${skillDirectory.name}\` is missing a \`name\` field in frontmatter.`, "Codex cannot expose skill metadata correctly without a stable skill name.", `Add a \`name\` field to the frontmatter in \`${skillFilePath}
|
|
272
|
+
findings.push(buildFailure("plugin.skill.name.missing", `The skill \`${skillDirectory.name}\` is missing a \`name\` field in frontmatter.`, "Codex cannot expose skill metadata correctly without a stable skill name.", `Add a \`name\` field to the frontmatter in \`${skillFilePath}\`.`, {
|
|
273
|
+
skillName: skillDirectory.name,
|
|
274
|
+
skillPath: relativePackagePath(rootPath, skillFilePath),
|
|
275
|
+
field: "name"
|
|
276
|
+
}));
|
|
264
277
|
}
|
|
265
278
|
if (!frontmatter?.description) {
|
|
266
|
-
findings.push(buildFailure("plugin.skill.description.missing", `The skill \`${skillDirectory.name}\` is missing a \`description\` field in frontmatter.`, "Codex uses skill descriptions for discovery and implicit matching, so missing descriptions reduce skill usability.", `Add a scoped \`description\` field to the frontmatter in \`${skillFilePath}
|
|
279
|
+
findings.push(buildFailure("plugin.skill.description.missing", `The skill \`${skillDirectory.name}\` is missing a \`description\` field in frontmatter.`, "Codex uses skill descriptions for discovery and implicit matching, so missing descriptions reduce skill usability.", `Add a scoped \`description\` field to the frontmatter in \`${skillFilePath}\`.`, {
|
|
280
|
+
skillName: skillDirectory.name,
|
|
281
|
+
skillPath: relativePackagePath(rootPath, skillFilePath),
|
|
282
|
+
field: "description"
|
|
283
|
+
}));
|
|
267
284
|
}
|
|
268
285
|
if (frontmatter?.description &&
|
|
269
286
|
isDescriptionLikelyVerbose(frontmatter.description, "skill")) {
|
|
270
|
-
findings.push(buildWarning("plugin.heuristic.skill_description.too_long", `The skill \`${skillDirectory.name}\` description is likely too verbose.`, "Overly long skill descriptions increase context cost and reduce the precision of skill matching.", `Shorten the \`description\` field in \`${skillFilePath}\` to a tightly scoped summary
|
|
287
|
+
findings.push(buildWarning("plugin.heuristic.skill_description.too_long", `The skill \`${skillDirectory.name}\` description is likely too verbose.`, "Overly long skill descriptions increase context cost and reduce the precision of skill matching.", `Shorten the \`description\` field in \`${skillFilePath}\` to a tightly scoped summary.`, {
|
|
288
|
+
skillName: skillDirectory.name,
|
|
289
|
+
skillPath: relativePackagePath(rootPath, skillFilePath),
|
|
290
|
+
field: "description"
|
|
291
|
+
}));
|
|
271
292
|
}
|
|
272
293
|
for (const supportReference of extractSupportAssetReferences(skillContent)) {
|
|
273
294
|
const supportPath = path.resolve(skillRoot, supportReference);
|
|
274
295
|
const supportFileExists = await fileExists(supportPath);
|
|
275
296
|
const supportDirectoryExists = await directoryExists(supportPath);
|
|
276
297
|
if (!supportFileExists && !supportDirectoryExists) {
|
|
277
|
-
findings.push(buildWarning("plugin.skill.asset_reference.missing", `The skill \`${skillDirectory.name}\` references missing support asset \`${supportReference}\`.`, "Skills that point to missing scripts, templates, assets, or examples are harder to execute and can fail when an agent follows the instructions.", `Create \`${supportPath}\` or update the reference in \`${skillFilePath}
|
|
298
|
+
findings.push(buildWarning("plugin.skill.asset_reference.missing", `The skill \`${skillDirectory.name}\` references missing support asset \`${supportReference}\`.`, "Skills that point to missing scripts, templates, assets, or examples are harder to execute and can fail when an agent follows the instructions.", `Create \`${supportPath}\` or update the reference in \`${skillFilePath}\`.`, {
|
|
299
|
+
skillName: skillDirectory.name,
|
|
300
|
+
skillPath: relativePackagePath(rootPath, skillFilePath),
|
|
301
|
+
assetReference: supportReference,
|
|
302
|
+
assetPath: relativePackagePath(rootPath, supportPath)
|
|
303
|
+
}));
|
|
278
304
|
}
|
|
279
305
|
}
|
|
280
306
|
}
|
|
@@ -299,7 +325,12 @@ async function validateMcpConfig(discoveredPackage) {
|
|
|
299
325
|
const mcpConfigExists = await fileExists(mcpConfigPath);
|
|
300
326
|
if (!mcpConfigExists) {
|
|
301
327
|
return [
|
|
302
|
-
buildFailure("plugin.mcp.path.missing", "The plugin manifest points to a missing `.mcp.json` file.", "Codex cannot load bundled MCP server definitions if the referenced config file does not exist.", `Create \`${mcpConfigPath}\` or update the manifest \`mcpServers\` path
|
|
328
|
+
buildFailure("plugin.mcp.path.missing", "The plugin manifest points to a missing `.mcp.json` file.", "Codex cannot load bundled MCP server definitions if the referenced config file does not exist.", `Create \`${mcpConfigPath}\` or update the manifest \`mcpServers\` path.`, {
|
|
329
|
+
manifestPath: relativePackagePath(rootPath, discoveredPackage.manifestPath),
|
|
330
|
+
field: "mcpServers",
|
|
331
|
+
configuredPath: manifest.mcpServers,
|
|
332
|
+
configPath: relativePackagePath(rootPath, mcpConfigPath)
|
|
333
|
+
})
|
|
303
334
|
];
|
|
304
335
|
}
|
|
305
336
|
let parsedConfig;
|
|
@@ -308,31 +339,42 @@ async function validateMcpConfig(discoveredPackage) {
|
|
|
308
339
|
}
|
|
309
340
|
catch {
|
|
310
341
|
return [
|
|
311
|
-
buildFailure("plugin.mcp.invalid_json", "The referenced `.mcp.json` file is not valid JSON.", "Codex will not be able to parse bundled MCP server configuration.", `Fix the JSON syntax in \`${mcpConfigPath}
|
|
342
|
+
buildFailure("plugin.mcp.invalid_json", "The referenced `.mcp.json` file is not valid JSON.", "Codex will not be able to parse bundled MCP server configuration.", `Fix the JSON syntax in \`${mcpConfigPath}\`.`, { configPath: relativePackagePath(rootPath, mcpConfigPath), field: "json" })
|
|
312
343
|
];
|
|
313
344
|
}
|
|
314
345
|
if (!isPlainObject(parsedConfig)) {
|
|
315
346
|
return [
|
|
316
|
-
buildFailure("plugin.mcp.invalid_shape", "The referenced `.mcp.json` file must contain a JSON object.", "Codex expects bundled MCP configuration to be object-shaped so server entries can be resolved reliably.", `Wrap the MCP configuration in a top-level object inside \`${mcpConfigPath}
|
|
347
|
+
buildFailure("plugin.mcp.invalid_shape", "The referenced `.mcp.json` file must contain a JSON object.", "Codex expects bundled MCP configuration to be object-shaped so server entries can be resolved reliably.", `Wrap the MCP configuration in a top-level object inside \`${mcpConfigPath}\`.`, { configPath: relativePackagePath(rootPath, mcpConfigPath), field: "root" })
|
|
317
348
|
];
|
|
318
349
|
}
|
|
319
350
|
const servers = parsedConfig.mcpServers;
|
|
320
351
|
if (!isPlainObject(servers) || Object.keys(servers).length === 0) {
|
|
321
352
|
return [
|
|
322
|
-
buildFailure("plugin.mcp.invalid_shape", "The referenced `.mcp.json` file must contain a non-empty `mcpServers` object.", "Without a valid `mcpServers` object, Codex cannot discover the bundled MCP server definitions.", `Define bundled servers under \`mcpServers\` in \`${mcpConfigPath}
|
|
353
|
+
buildFailure("plugin.mcp.invalid_shape", "The referenced `.mcp.json` file must contain a non-empty `mcpServers` object.", "Without a valid `mcpServers` object, Codex cannot discover the bundled MCP server definitions.", `Define bundled servers under \`mcpServers\` in \`${mcpConfigPath}\`.`, {
|
|
354
|
+
configPath: relativePackagePath(rootPath, mcpConfigPath),
|
|
355
|
+
field: "mcpServers"
|
|
356
|
+
})
|
|
323
357
|
];
|
|
324
358
|
}
|
|
325
359
|
const findings = [];
|
|
326
360
|
for (const [serverName, serverConfig] of Object.entries(servers)) {
|
|
327
361
|
if (!isPlainObject(serverConfig)) {
|
|
328
|
-
findings.push(buildFailure("plugin.mcp.server.invalid", `The MCP server \`${serverName}\` must be configured as an object.`, "Codex cannot interpret a server entry unless it is represented as an object with server options.", `Change the \`${serverName}\` entry in \`${mcpConfigPath}\` to an object
|
|
362
|
+
findings.push(buildFailure("plugin.mcp.server.invalid", `The MCP server \`${serverName}\` must be configured as an object.`, "Codex cannot interpret a server entry unless it is represented as an object with server options.", `Change the \`${serverName}\` entry in \`${mcpConfigPath}\` to an object.`, {
|
|
363
|
+
configPath: relativePackagePath(rootPath, mcpConfigPath),
|
|
364
|
+
serverName,
|
|
365
|
+
field: "server"
|
|
366
|
+
}));
|
|
329
367
|
continue;
|
|
330
368
|
}
|
|
331
369
|
const command = serverConfig.command;
|
|
332
370
|
const url = serverConfig.url;
|
|
333
371
|
const env = serverConfig.env;
|
|
334
372
|
if (typeof command !== "string" && typeof url !== "string") {
|
|
335
|
-
findings.push(buildFailure("plugin.mcp.server.transport.missing", `The MCP server \`${serverName}\` must define either \`command\` or \`url\`.`, "Codex needs a process command for STDIO servers or a URL for streamable HTTP servers.", `Add either \`command\` or \`url\` to the \`${serverName}\` entry in \`${mcpConfigPath}
|
|
373
|
+
findings.push(buildFailure("plugin.mcp.server.transport.missing", `The MCP server \`${serverName}\` must define either \`command\` or \`url\`.`, "Codex needs a process command for STDIO servers or a URL for streamable HTTP servers.", `Add either \`command\` or \`url\` to the \`${serverName}\` entry in \`${mcpConfigPath}\`.`, {
|
|
374
|
+
configPath: relativePackagePath(rootPath, mcpConfigPath),
|
|
375
|
+
serverName,
|
|
376
|
+
field: "transport"
|
|
377
|
+
}));
|
|
336
378
|
}
|
|
337
379
|
if (isPlainObject(env)) {
|
|
338
380
|
for (const [envKey, envValue] of Object.entries(env)) {
|
package/dist/domain/types.d.ts
CHANGED
|
@@ -10,11 +10,25 @@ export interface Finding {
|
|
|
10
10
|
evidence?: FindingEvidence;
|
|
11
11
|
fingerprint?: string;
|
|
12
12
|
}
|
|
13
|
+
export interface FindingSuppression {
|
|
14
|
+
reason: string;
|
|
15
|
+
expiresAt: string;
|
|
16
|
+
}
|
|
17
|
+
export interface SuppressedFinding extends Finding {
|
|
18
|
+
suppression: FindingSuppression;
|
|
19
|
+
}
|
|
20
|
+
export interface SuppressionSummary {
|
|
21
|
+
applied: number;
|
|
22
|
+
expired: number;
|
|
23
|
+
invalid: number;
|
|
24
|
+
}
|
|
13
25
|
export interface CheckResult {
|
|
14
26
|
targetPath: string;
|
|
15
27
|
status: "pass" | "warn" | "fail";
|
|
16
28
|
exitCode: 0 | 1;
|
|
17
29
|
findings: Finding[];
|
|
30
|
+
suppressedFindings?: SuppressedFinding[];
|
|
31
|
+
suppressionSummary?: SuppressionSummary;
|
|
18
32
|
runtimeScorecard?: RuntimeScorecard;
|
|
19
33
|
}
|
|
20
34
|
export interface CheckOptions {
|
|
@@ -50,6 +64,8 @@ export interface JsonReport {
|
|
|
50
64
|
generatedAt: string;
|
|
51
65
|
summary: JsonReportSummary;
|
|
52
66
|
findings: Finding[];
|
|
67
|
+
suppressedFindings?: SuppressedFinding[];
|
|
68
|
+
suppressionSummary?: SuppressionSummary;
|
|
53
69
|
}
|
|
54
70
|
export type RuntimeCapabilityStatus = "pass" | "fail" | "warn" | "skipped" | "unsupported";
|
|
55
71
|
export interface RuntimeScorecard {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CheckOptions, CheckResult } from "./domain/types.js";
|
|
2
|
+
export { applyDoctorConfig, loadDoctorConfig, type DoctorConfig } from "./core/doctor-config.js";
|
|
2
3
|
export { buildFindingFingerprint, formatFindingFingerprintLine, withFindingFingerprint, withFindingFingerprints } from "./reporting/finding-fingerprint.js";
|
|
3
4
|
export { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard, type SecurityAudit } from "./security/security-audit.js";
|
|
4
5
|
export { buildTrustScore, renderTrustScore, renderTrustScoreJson, type BuildTrustScoreOptions, type TrustScoreReport } from "./security/trust-score.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { validatePlugin } from "./core/validate-plugin.js";
|
|
2
|
+
export { applyDoctorConfig, loadDoctorConfig } from "./core/doctor-config.js";
|
|
2
3
|
export { buildFindingFingerprint, formatFindingFingerprintLine, withFindingFingerprint, withFindingFingerprints } from "./reporting/finding-fingerprint.js";
|
|
3
4
|
export { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard } from "./security/security-audit.js";
|
|
4
5
|
export { buildTrustScore, renderTrustScore, renderTrustScoreJson } from "./security/trust-score.js";
|
|
@@ -4,13 +4,14 @@ import { buildCompatibilityMatrix, readMcpConfigPath } from "../compatibility/co
|
|
|
4
4
|
import { readJsonFile } from "../core/read-json-file.js";
|
|
5
5
|
import { formatFindingFingerprintLine, withFindingFingerprints } from "../reporting/finding-fingerprint.js";
|
|
6
6
|
import { auditMcpServerConfig, buildSecurityAuditFromFindings } from "../security/security-audit.js";
|
|
7
|
-
function buildFinding(severity, id, message, impact, suggestedFix) {
|
|
7
|
+
function buildFinding(severity, id, message, impact, suggestedFix, evidence) {
|
|
8
8
|
return {
|
|
9
9
|
id,
|
|
10
10
|
severity,
|
|
11
11
|
message,
|
|
12
12
|
impact,
|
|
13
|
-
suggestedFix
|
|
13
|
+
suggestedFix,
|
|
14
|
+
...(evidence ? { evidence } : {})
|
|
14
15
|
};
|
|
15
16
|
}
|
|
16
17
|
function isPlainObject(value) {
|
|
@@ -30,12 +31,12 @@ function isPathWithinRoot(rootPath, candidatePath) {
|
|
|
30
31
|
return (relativePath === "" ||
|
|
31
32
|
(!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
|
|
32
33
|
}
|
|
33
|
-
function buildStaticMcpFindings(
|
|
34
|
-
if (!
|
|
34
|
+
function buildStaticMcpFindings(configPath, parsedConfig) {
|
|
35
|
+
if (!configPath) {
|
|
35
36
|
return {
|
|
36
37
|
serverCount: 0,
|
|
37
38
|
findings: [
|
|
38
|
-
buildFinding("fail", "mcp.config.missing", "No MCP config was found for this target.", "A generic MCP package needs a `.mcp.json` file or a Codex manifest `mcpServers` reference before clients can discover servers.", "Add `.mcp.json` with a non-empty top-level `mcpServers` object.")
|
|
39
|
+
buildFinding("fail", "mcp.config.missing", "No MCP config was found for this target.", "A generic MCP package needs a `.mcp.json` file or a Codex manifest `mcpServers` reference before clients can discover servers.", "Add `.mcp.json` with a non-empty top-level `mcpServers` object.", { configPath: ".mcp.json", field: "mcpServers" })
|
|
39
40
|
]
|
|
40
41
|
};
|
|
41
42
|
}
|
|
@@ -43,7 +44,7 @@ function buildStaticMcpFindings(mcpConfigPath, parsedConfig) {
|
|
|
43
44
|
return {
|
|
44
45
|
serverCount: 0,
|
|
45
46
|
findings: [
|
|
46
|
-
buildFinding("fail", "mcp.config.invalid_shape", "The MCP config must be a JSON object.", "MCP clients expect object-shaped configuration so server entries can be resolved deterministically.", `Wrap the MCP config in a top-level object inside \`${
|
|
47
|
+
buildFinding("fail", "mcp.config.invalid_shape", "The MCP config must be a JSON object.", "MCP clients expect object-shaped configuration so server entries can be resolved deterministically.", `Wrap the MCP config in a top-level object inside \`${configPath}\`.`, { configPath, field: "root" })
|
|
47
48
|
]
|
|
48
49
|
};
|
|
49
50
|
}
|
|
@@ -52,18 +53,18 @@ function buildStaticMcpFindings(mcpConfigPath, parsedConfig) {
|
|
|
52
53
|
return {
|
|
53
54
|
serverCount: 0,
|
|
54
55
|
findings: [
|
|
55
|
-
buildFinding("fail", "mcp.config.invalid_shape", "The MCP config must contain a non-empty `mcpServers` object.", "Without server entries, MCP clients cannot discover any package capabilities.", `Define MCP servers under \`mcpServers\` in \`${
|
|
56
|
+
buildFinding("fail", "mcp.config.invalid_shape", "The MCP config must contain a non-empty `mcpServers` object.", "Without server entries, MCP clients cannot discover any package capabilities.", `Define MCP servers under \`mcpServers\` in \`${configPath}\`.`, { configPath, field: "mcpServers" })
|
|
56
57
|
]
|
|
57
58
|
};
|
|
58
59
|
}
|
|
59
60
|
const findings = [];
|
|
60
61
|
for (const [serverName, serverConfig] of Object.entries(servers)) {
|
|
61
62
|
if (!isPlainObject(serverConfig)) {
|
|
62
|
-
findings.push(buildFinding("fail", "mcp.server.invalid", `The MCP server \`${serverName}\` must be configured as an object.`, "MCP clients cannot interpret a server entry unless it is represented as an object with server options.", `Change the \`${serverName}\` entry in \`${
|
|
63
|
+
findings.push(buildFinding("fail", "mcp.server.invalid", `The MCP server \`${serverName}\` must be configured as an object.`, "MCP clients cannot interpret a server entry unless it is represented as an object with server options.", `Change the \`${serverName}\` entry in \`${configPath}\` to an object.`, { configPath, serverName, field: "server" }));
|
|
63
64
|
continue;
|
|
64
65
|
}
|
|
65
66
|
if (typeof serverConfig.command !== "string" && typeof serverConfig.url !== "string") {
|
|
66
|
-
findings.push(buildFinding("fail", "mcp.server.transport.missing", `The MCP server \`${serverName}\` must define either \`command\` or \`url\`.`, "MCP clients need a process command for stdio servers or a URL for remote servers.", `Add either \`command\` or \`url\` to the \`${serverName}\` entry in \`${
|
|
67
|
+
findings.push(buildFinding("fail", "mcp.server.transport.missing", `The MCP server \`${serverName}\` must define either \`command\` or \`url\`.`, "MCP clients need a process command for stdio servers or a URL for remote servers.", `Add either \`command\` or \`url\` to the \`${serverName}\` entry in \`${configPath}\`.`, { configPath, serverName, field: "transport" }));
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
return {
|
|
@@ -92,19 +93,26 @@ export async function buildGenericMcpDoctor(targetPath, environment = {}) {
|
|
|
92
93
|
}
|
|
93
94
|
else if (!isPathWithinRoot(rootPath, mcpConfigPath)) {
|
|
94
95
|
staticFindings = [
|
|
95
|
-
buildFinding("fail", "mcp.config.path_outside_root", "The MCP config path resolves outside the target root.", "A package that reads MCP configuration outside its root is harder to audit and can depend on unreviewed local files.", "Keep `.mcp.json` or the manifest `mcpServers` reference inside the package root."
|
|
96
|
+
buildFinding("fail", "mcp.config.path_outside_root", "The MCP config path resolves outside the target root.", "A package that reads MCP configuration outside its root is harder to audit and can depend on unreviewed local files.", "Keep `.mcp.json` or the manifest `mcpServers` reference inside the package root.", {
|
|
97
|
+
configPath: path.relative(rootPath, mcpConfigPath).replaceAll("\\", "/"),
|
|
98
|
+
resolvedPath: mcpConfigPath,
|
|
99
|
+
field: "configPath"
|
|
100
|
+
})
|
|
96
101
|
];
|
|
97
102
|
}
|
|
98
103
|
else {
|
|
99
104
|
try {
|
|
100
105
|
parsedConfig = await readJsonFile(mcpConfigPath);
|
|
101
|
-
const staticResult = buildStaticMcpFindings(mcpConfigPath, parsedConfig);
|
|
106
|
+
const staticResult = buildStaticMcpFindings(path.relative(rootPath, mcpConfigPath).replaceAll("\\", "/"), parsedConfig);
|
|
102
107
|
staticFindings = staticResult.findings;
|
|
103
108
|
serverCount = staticResult.serverCount;
|
|
104
109
|
}
|
|
105
110
|
catch {
|
|
106
111
|
staticFindings = [
|
|
107
|
-
buildFinding("fail", "mcp.config.invalid_json", "The MCP config is not valid JSON.", "MCP clients cannot parse server configuration until the JSON syntax is valid.", `Fix the JSON syntax in \`${mcpConfigPath}
|
|
112
|
+
buildFinding("fail", "mcp.config.invalid_json", "The MCP config is not valid JSON.", "MCP clients cannot parse server configuration until the JSON syntax is valid.", `Fix the JSON syntax in \`${mcpConfigPath}\`.`, {
|
|
113
|
+
configPath: path.relative(rootPath, mcpConfigPath).replaceAll("\\", "/"),
|
|
114
|
+
field: "json"
|
|
115
|
+
})
|
|
108
116
|
];
|
|
109
117
|
}
|
|
110
118
|
}
|
|
@@ -18,7 +18,13 @@ export function buildJsonReport(result, options) {
|
|
|
18
18
|
total: result.findings.length
|
|
19
19
|
}
|
|
20
20
|
},
|
|
21
|
-
findings: result.findings
|
|
21
|
+
findings: result.findings,
|
|
22
|
+
...(result.suppressedFindings
|
|
23
|
+
? { suppressedFindings: result.suppressedFindings }
|
|
24
|
+
: {}),
|
|
25
|
+
...(result.suppressionSummary
|
|
26
|
+
? { suppressionSummary: result.suppressionSummary }
|
|
27
|
+
: {})
|
|
22
28
|
};
|
|
23
29
|
}
|
|
24
30
|
export function renderJsonReport(result, options) {
|
|
@@ -16,7 +16,10 @@ export function buildMarkdownReport(result, options) {
|
|
|
16
16
|
`| Warn Findings | ${warnCount} |`,
|
|
17
17
|
`| Total Findings | ${result.findings.length} |`
|
|
18
18
|
];
|
|
19
|
-
if (result.
|
|
19
|
+
if (result.suppressionSummary) {
|
|
20
|
+
lines.push(`| Suppressions Applied | ${result.suppressionSummary.applied} |`, `| Suppressions Expired | ${result.suppressionSummary.expired} |`, `| Suppressions Invalid | ${result.suppressionSummary.invalid} |`);
|
|
21
|
+
}
|
|
22
|
+
if (result.findings.length === 0 && !result.suppressedFindings?.length) {
|
|
20
23
|
lines.push("", "No findings.");
|
|
21
24
|
return lines.join("\n");
|
|
22
25
|
}
|
|
@@ -33,22 +36,39 @@ export function buildMarkdownReport(result, options) {
|
|
|
33
36
|
lines.push(`| prompts/list | ${result.runtimeScorecard.promptsList.toUpperCase()} |`);
|
|
34
37
|
lines.push(`| prompts/get | ${result.runtimeScorecard.promptGet.toUpperCase()} |`);
|
|
35
38
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
if (result.findings.length > 0) {
|
|
40
|
+
lines.push("", "## Findings", "");
|
|
41
|
+
for (const finding of result.findings) {
|
|
42
|
+
lines.push(`### ${finding.severity.toUpperCase()} \`${finding.id}\``);
|
|
43
|
+
lines.push("");
|
|
44
|
+
lines.push(`- Message: ${finding.message}`);
|
|
45
|
+
lines.push(`- Impact: ${finding.impact}`);
|
|
46
|
+
lines.push(`- Suggested fix: ${finding.suggestedFix}`);
|
|
47
|
+
const fingerprint = formatFindingFingerprintLine(finding);
|
|
48
|
+
if (fingerprint) {
|
|
49
|
+
lines.push(`- Fingerprint: \`${fingerprint}\``);
|
|
50
|
+
}
|
|
51
|
+
const evidence = formatFindingEvidenceLine(finding);
|
|
52
|
+
if (evidence) {
|
|
53
|
+
lines.push(`- Evidence: ${evidence}`);
|
|
54
|
+
}
|
|
55
|
+
lines.push("");
|
|
46
56
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
lines.push("", "No active findings.");
|
|
60
|
+
}
|
|
61
|
+
if (result.suppressedFindings?.length) {
|
|
62
|
+
lines.push("", "## Suppressed Findings", "");
|
|
63
|
+
for (const finding of result.suppressedFindings) {
|
|
64
|
+
lines.push(`### ${finding.severity.toUpperCase()} \`${finding.id}\``);
|
|
65
|
+
lines.push("");
|
|
66
|
+
lines.push(`- Message: ${finding.message}`);
|
|
67
|
+
lines.push(`- Fingerprint: \`${finding.fingerprint ?? "unavailable"}\``);
|
|
68
|
+
lines.push(`- Reason: ${finding.suppression.reason}`);
|
|
69
|
+
lines.push(`- Expires: ${finding.suppression.expiresAt}`);
|
|
70
|
+
lines.push("");
|
|
50
71
|
}
|
|
51
|
-
lines.push("");
|
|
52
72
|
}
|
|
53
73
|
return lines.join("\n");
|
|
54
74
|
}
|
|
@@ -55,7 +55,15 @@ export function renderSarifReport(result) {
|
|
|
55
55
|
}
|
|
56
56
|
: {}),
|
|
57
57
|
...(finding.evidence ? { properties: { evidence: finding.evidence } } : {})
|
|
58
|
-
}))
|
|
58
|
+
})),
|
|
59
|
+
...(result.suppressionSummary
|
|
60
|
+
? {
|
|
61
|
+
properties: {
|
|
62
|
+
suppressionSummary: result.suppressionSummary,
|
|
63
|
+
suppressedFindings: result.suppressedFindings ?? []
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
: {})
|
|
59
67
|
}
|
|
60
68
|
]
|
|
61
69
|
};
|
|
@@ -35,7 +35,10 @@ export function renderTextReport(result, options = {}) {
|
|
|
35
35
|
`Target: ${result.targetPath}`,
|
|
36
36
|
`Summary: ${failCount} fail, ${warnCount} warn, ${totalCount} total`
|
|
37
37
|
];
|
|
38
|
-
if (result.
|
|
38
|
+
if (result.suppressionSummary) {
|
|
39
|
+
lines.push(`Suppressions: ${result.suppressionSummary.applied} applied, ${result.suppressionSummary.expired} expired, ${result.suppressionSummary.invalid} invalid`);
|
|
40
|
+
}
|
|
41
|
+
if (result.findings.length === 0 && !result.suppressedFindings?.length) {
|
|
39
42
|
if (result.runtimeScorecard) {
|
|
40
43
|
lines.push("", "Runtime Scorecard", "----------------");
|
|
41
44
|
lines.push(`initialize: ${result.runtimeScorecard.initialize}`);
|
|
@@ -82,6 +85,16 @@ export function renderTextReport(result, options = {}) {
|
|
|
82
85
|
};
|
|
83
86
|
appendSection("Failures", failures, glyphs.fail);
|
|
84
87
|
appendSection("Warnings", warnings, glyphs.warn);
|
|
88
|
+
if (result.suppressedFindings?.length) {
|
|
89
|
+
lines.push("", "Suppressed Findings", "-------------------");
|
|
90
|
+
for (const finding of result.suppressedFindings) {
|
|
91
|
+
lines.push(`- ${finding.id}`);
|
|
92
|
+
lines.push(` Message: ${finding.message}`);
|
|
93
|
+
lines.push(` Fingerprint: ${finding.fingerprint ?? "unavailable"}`);
|
|
94
|
+
lines.push(` Reason: ${finding.suppression.reason}`);
|
|
95
|
+
lines.push(` Expires: ${finding.suppression.expiresAt}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
85
98
|
if (result.runtimeScorecard) {
|
|
86
99
|
lines.push("", "Runtime Scorecard", "----------------");
|
|
87
100
|
lines.push(`initialize: ${result.runtimeScorecard.initialize}`);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type RuleCategory = "package" | "skill" | "mcp" | "runtime" | "security";
|
|
1
|
+
export type RuleCategory = "package" | "skill" | "mcp" | "runtime" | "security" | "configuration";
|
|
2
2
|
export type RuleSeverity = "fail" | "warn";
|
|
3
3
|
export interface RuleDefinition {
|
|
4
4
|
id: string;
|
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
export const ruleCatalog = [
|
|
2
|
+
{
|
|
3
|
+
id: "suppression.invalid",
|
|
4
|
+
category: "configuration",
|
|
5
|
+
defaultSeverity: "warn",
|
|
6
|
+
summary: "A targeted suppression record is invalid.",
|
|
7
|
+
why: "Invalid suppression records are not applied and can create false confidence about accepted findings.",
|
|
8
|
+
fix: "Fix or remove the invalid suppression record in `.codex-doctor.json`.",
|
|
9
|
+
example: '{ "fingerprint": "<64 lowercase hex characters>", "reason": "Reviewed exception.", "expiresAt": "2026-07-31" }'
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
id: "suppression.expired",
|
|
13
|
+
category: "configuration",
|
|
14
|
+
defaultSeverity: "warn",
|
|
15
|
+
summary: "A targeted suppression has expired.",
|
|
16
|
+
why: "Expired risk acceptance must be reviewed again before the finding can be suppressed.",
|
|
17
|
+
fix: "Remove the expired record or replace it with a newly reviewed expiration date.",
|
|
18
|
+
example: '{ "fingerprint": "<fingerprint>", "reason": "Reviewed again.", "expiresAt": "2026-08-31" }'
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
id: "plugin.manifest.missing",
|
|
4
22
|
category: "package",
|
|
@@ -331,7 +331,7 @@ export async function buildSecurityAudit(targetPath) {
|
|
|
331
331
|
const discoveredPackage = await discoverPackage(targetPath);
|
|
332
332
|
if (!discoveredPackage) {
|
|
333
333
|
const findings = [
|
|
334
|
-
buildFinding("fail", "plugin.security.audit_unavailable", "The target directory is missing `.codex-plugin/plugin.json`, so the package security audit cannot run.", "Without a Codex plugin manifest, the audit cannot resolve packaged skills or MCP server configuration safely.", "Run the audit against a Codex plugin package root.", {
|
|
334
|
+
buildFinding("fail", "plugin.security.audit_unavailable", "The target directory is missing `.codex-plugin/plugin.json`, so the package security audit cannot run.", "Without a Codex plugin manifest, the audit cannot resolve packaged skills or MCP server configuration safely.", "Run the audit against a Codex plugin package root.", { manifestPath: ".codex-plugin/plugin.json" })
|
|
335
335
|
];
|
|
336
336
|
return buildSecurityAuditFromFindings(targetPath, findings);
|
|
337
337
|
}
|
|
@@ -11,13 +11,14 @@ const lifecycleScripts = new Set([
|
|
|
11
11
|
"prepublish",
|
|
12
12
|
"prepare"
|
|
13
13
|
]);
|
|
14
|
-
function buildFinding(severity, id, message, impact, suggestedFix) {
|
|
14
|
+
function buildFinding(severity, id, message, impact, suggestedFix, evidence) {
|
|
15
15
|
return {
|
|
16
16
|
id,
|
|
17
17
|
severity,
|
|
18
18
|
message,
|
|
19
19
|
impact,
|
|
20
|
-
suggestedFix
|
|
20
|
+
suggestedFix,
|
|
21
|
+
...(evidence ? { evidence } : {})
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
24
|
async function fileExists(targetPath) {
|
|
@@ -61,10 +62,10 @@ function auditScripts(packageJson) {
|
|
|
61
62
|
}
|
|
62
63
|
scriptsChecked += 1;
|
|
63
64
|
if (containsRemotePipeInstall(scriptValue)) {
|
|
64
|
-
findings.push(buildFinding("fail", "trust.package.remote_pipe_install", `The package lifecycle script \`${scriptName}\` pipes remote content into a shell.`, "Remote download-and-execute scripts can run unreviewed code during install or publish workflows.", "Replace remote pipe execution with pinned package dependencies or a checked-in reviewed setup script."));
|
|
65
|
+
findings.push(buildFinding("fail", "trust.package.remote_pipe_install", `The package lifecycle script \`${scriptName}\` pipes remote content into a shell.`, "Remote download-and-execute scripts can run unreviewed code during install or publish workflows.", "Replace remote pipe execution with pinned package dependencies or a checked-in reviewed setup script.", { packageJsonPath: "package.json", scriptName }));
|
|
65
66
|
continue;
|
|
66
67
|
}
|
|
67
|
-
findings.push(buildFinding("warn", "trust.package.lifecycle_script", `The package defines lifecycle script \`${scriptName}\`.`, "Lifecycle scripts execute automatically during package manager workflows and increase supply-chain review scope.", "Keep lifecycle scripts minimal, documented, and covered by release review."));
|
|
68
|
+
findings.push(buildFinding("warn", "trust.package.lifecycle_script", `The package defines lifecycle script \`${scriptName}\`.`, "Lifecycle scripts execute automatically during package manager workflows and increase supply-chain review scope.", "Keep lifecycle scripts minimal, documented, and covered by release review.", { packageJsonPath: "package.json", scriptName }));
|
|
68
69
|
}
|
|
69
70
|
return {
|
|
70
71
|
findings,
|
|
@@ -73,26 +74,41 @@ function auditScripts(packageJson) {
|
|
|
73
74
|
}
|
|
74
75
|
function dependencySections(packageJson) {
|
|
75
76
|
return [
|
|
76
|
-
packageJson.dependencies,
|
|
77
|
-
packageJson.devDependencies,
|
|
78
|
-
packageJson.optionalDependencies,
|
|
79
|
-
packageJson.peerDependencies
|
|
80
|
-
]
|
|
77
|
+
{ name: "dependencies", dependencies: packageJson.dependencies },
|
|
78
|
+
{ name: "devDependencies", dependencies: packageJson.devDependencies },
|
|
79
|
+
{ name: "optionalDependencies", dependencies: packageJson.optionalDependencies },
|
|
80
|
+
{ name: "peerDependencies", dependencies: packageJson.peerDependencies }
|
|
81
|
+
]
|
|
82
|
+
.filter((entry) => isPlainObject(entry.dependencies))
|
|
83
|
+
.map((entry) => ({
|
|
84
|
+
name: entry.name,
|
|
85
|
+
dependencies: entry.dependencies
|
|
86
|
+
}));
|
|
81
87
|
}
|
|
82
88
|
function auditDependencies(packageJson) {
|
|
83
89
|
const findings = [];
|
|
84
90
|
let dependenciesChecked = 0;
|
|
85
|
-
for (const dependencies of dependencySections(packageJson)) {
|
|
91
|
+
for (const { name: dependencySection, dependencies } of dependencySections(packageJson)) {
|
|
86
92
|
for (const [dependencyName, versionSpec] of Object.entries(dependencies)) {
|
|
87
93
|
if (typeof versionSpec !== "string") {
|
|
88
94
|
continue;
|
|
89
95
|
}
|
|
90
96
|
dependenciesChecked += 1;
|
|
91
97
|
if (versionSpec === "*" || versionSpec.toLowerCase() === "latest") {
|
|
92
|
-
findings.push(buildFinding("warn", "trust.package.unpinned_dependency", `The dependency \`${dependencyName}\` uses broad version spec \`${versionSpec}\`.`, "Broad dependency ranges make package resolution less reproducible across installs and releases.", "Pin the dependency to a specific compatible range or exact version."
|
|
98
|
+
findings.push(buildFinding("warn", "trust.package.unpinned_dependency", `The dependency \`${dependencyName}\` uses broad version spec \`${versionSpec}\`.`, "Broad dependency ranges make package resolution less reproducible across installs and releases.", "Pin the dependency to a specific compatible range or exact version.", {
|
|
99
|
+
packageJsonPath: "package.json",
|
|
100
|
+
dependencyName,
|
|
101
|
+
dependencySection,
|
|
102
|
+
versionSpec
|
|
103
|
+
}));
|
|
93
104
|
}
|
|
94
105
|
if (/^(git\+|github:|http:\/\/|https:\/\/)/i.test(versionSpec)) {
|
|
95
|
-
findings.push(buildFinding("warn", "trust.package.remote_dependency", `The dependency \`${dependencyName}\` resolves from remote spec \`${versionSpec}\`.`, "Remote dependency specs can change outside the npm registry's normal version and integrity workflow.", "Prefer registry-published dependencies with pinned semver ranges."
|
|
106
|
+
findings.push(buildFinding("warn", "trust.package.remote_dependency", `The dependency \`${dependencyName}\` resolves from remote spec \`${versionSpec}\`.`, "Remote dependency specs can change outside the npm registry's normal version and integrity workflow.", "Prefer registry-published dependencies with pinned semver ranges.", {
|
|
107
|
+
packageJsonPath: "package.json",
|
|
108
|
+
dependencyName,
|
|
109
|
+
dependencySection,
|
|
110
|
+
versionSpec
|
|
111
|
+
}));
|
|
96
112
|
}
|
|
97
113
|
}
|
|
98
114
|
}
|
|
@@ -104,7 +120,7 @@ function auditDependencies(packageJson) {
|
|
|
104
120
|
function dedupeFindings(findings) {
|
|
105
121
|
const seen = new Set();
|
|
106
122
|
return findings.filter((finding) => {
|
|
107
|
-
const key = `${finding.id}\n${finding.
|
|
123
|
+
const key = `${finding.id}\n${JSON.stringify(finding.evidence ?? {})}`;
|
|
108
124
|
if (seen.has(key)) {
|
|
109
125
|
return false;
|
|
110
126
|
}
|
package/package.json
CHANGED