@praneeth_54/agentdoctor 0.2.0-beta → 1.0.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/CHANGELOG.md +107 -3
- package/README.md +86 -35
- package/dist/agents/inspect.js +58 -51
- package/dist/cli/commands/doctor.js +1 -1
- package/dist/cli/commands/explain.js +2 -2
- package/dist/cli/commands/fix.d.ts +7 -5
- package/dist/cli/commands/fix.js +73 -12
- package/dist/cli/commands/scan.d.ts +5 -0
- package/dist/cli/commands/scan.js +25 -6
- package/dist/cli/commands/verify.d.ts +19 -0
- package/dist/cli/commands/verify.js +70 -0
- package/dist/cli/program.js +97 -33
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/core/fix/apply.d.ts +14 -0
- package/dist/core/fix/apply.js +47 -0
- package/dist/core/fix/patterns.d.ts +7 -0
- package/dist/core/fix/patterns.js +21 -0
- package/dist/core/fix/plan.d.ts +9 -0
- package/dist/core/fix/plan.js +247 -0
- package/dist/core/fix/render.d.ts +8 -0
- package/dist/core/fix/render.js +116 -0
- package/dist/core/fix/run.d.ts +15 -0
- package/dist/core/fix/run.js +58 -0
- package/dist/core/fix/types.d.ts +34 -0
- package/dist/core/fix/types.js +6 -0
- package/dist/core/fix/writers/claude-settings.d.ts +16 -0
- package/dist/core/fix/writers/claude-settings.js +100 -0
- package/dist/core/fix/writers/codex-config.d.ts +22 -0
- package/dist/core/fix/writers/codex-config.js +183 -0
- package/dist/core/fix/writers/cursorignore.d.ts +13 -0
- package/dist/core/fix/writers/cursorignore.js +68 -0
- package/dist/core/path-resolution/index.d.ts +5 -0
- package/dist/core/path-resolution/index.js +5 -0
- package/dist/core/path-resolution/prepare.d.ts +33 -0
- package/dist/core/path-resolution/prepare.js +98 -0
- package/dist/core/policy/evaluate.d.ts +44 -0
- package/dist/core/policy/evaluate.js +120 -0
- package/dist/core/rules/claude-deny.d.ts +8 -0
- package/dist/core/rules/claude-deny.js +39 -0
- package/dist/core/rules/codex-deny.d.ts +5 -0
- package/dist/core/rules/codex-deny.js +42 -0
- package/dist/core/rules/context/generated-directory.js +52 -8
- package/dist/core/rules/context/large-log-file.js +27 -10
- package/dist/core/rules/ignore.js +2 -5
- package/dist/core/rules/instructions/missing-path-reference.d.ts +5 -1
- package/dist/core/rules/instructions/missing-path-reference.js +114 -7
- package/dist/core/rules/path-kind.d.ts +27 -0
- package/dist/core/rules/path-kind.js +180 -0
- package/dist/core/rules/security/env-file-exposure.js +23 -38
- package/dist/core/rules/security/private-key-file.js +4 -0
- package/dist/core/rules/text-cache.js +46 -40
- package/dist/core/verify/compare.d.ts +29 -0
- package/dist/core/verify/compare.js +56 -0
- package/dist/core/verify/load-baseline.d.ts +15 -0
- package/dist/core/verify/load-baseline.js +71 -0
- package/dist/core/verify/verify.d.ts +26 -0
- package/dist/core/verify/verify.js +40 -0
- package/dist/discovery/files.js +7 -2
- package/dist/discovery/log-like.d.ts +2 -0
- package/dist/discovery/log-like.js +7 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/reporters/github/annotations.d.ts +8 -0
- package/dist/reporters/github/annotations.js +42 -0
- package/dist/reporters/github/emit.d.ts +23 -0
- package/dist/reporters/github/emit.js +31 -0
- package/dist/reporters/github/summary.d.ts +20 -0
- package/dist/reporters/github/summary.js +111 -0
- package/dist/reporters/terminal/report.d.ts +5 -0
- package/dist/reporters/terminal/report.js +73 -4
- package/dist/reporters/verify/json.d.ts +5 -0
- package/dist/reporters/verify/json.js +51 -0
- package/dist/reporters/verify/terminal.d.ts +6 -0
- package/dist/reporters/verify/terminal.js +71 -0
- package/dist/utils/fs.d.ts +5 -0
- package/dist/utils/fs.js +34 -0
- package/dist/utils/path.d.ts +5 -0
- package/dist/utils/path.js +16 -0
- package/package.json +11 -1
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { claudeDeniesPath } from "../claude-deny.js";
|
|
2
|
+
import { codexDeniesPath } from "../codex-deny.js";
|
|
3
|
+
import { isSampleOrTestPath } from "../path-kind.js";
|
|
1
4
|
const TEMPLATE_BASENAMES = new Set([".env.example", ".env.sample", ".env.template", ".env.dist"]);
|
|
2
5
|
const BACKUP_BASENAMES = new Set([".env_backup", ".env_old", ".env_local"]);
|
|
3
6
|
/**
|
|
@@ -10,11 +13,18 @@ export function classifyEnvBasename(base) {
|
|
|
10
13
|
return null;
|
|
11
14
|
}
|
|
12
15
|
const lower = normalized.toLowerCase();
|
|
16
|
+
if (BACKUP_BASENAMES.has(lower)) {
|
|
17
|
+
return "backup";
|
|
18
|
+
}
|
|
13
19
|
if (TEMPLATE_BASENAMES.has(lower)) {
|
|
14
20
|
return "template";
|
|
15
21
|
}
|
|
16
|
-
|
|
17
|
-
|
|
22
|
+
// .env.local.example, .env.testing.example, .env.example.local, etc.
|
|
23
|
+
if (/\.(?:example|sample|template)(?:\.|$)/i.test(normalized)) {
|
|
24
|
+
return "template";
|
|
25
|
+
}
|
|
26
|
+
if (/\.env(?:\.[A-Za-z0-9_-]+)*\.(?:example|sample|template)$/i.test(normalized)) {
|
|
27
|
+
return "template";
|
|
18
28
|
}
|
|
19
29
|
if (lower === ".env") {
|
|
20
30
|
return "runtime";
|
|
@@ -54,28 +64,6 @@ function agentsWithoutClearExclusion(context, relativePath) {
|
|
|
54
64
|
}
|
|
55
65
|
return affected;
|
|
56
66
|
}
|
|
57
|
-
async function claudeDeniesRead(context, relativePath) {
|
|
58
|
-
const settingsFiles = context.agents
|
|
59
|
-
.find((a) => a.id === "claude-code")
|
|
60
|
-
?.configFiles.filter((f) => f.kind === "claude-settings" || f.kind === "claude-settings-local") ?? [];
|
|
61
|
-
for (const file of settingsFiles) {
|
|
62
|
-
const cached = await context.textCache.read(file.relativePath);
|
|
63
|
-
if (!cached.text)
|
|
64
|
-
continue;
|
|
65
|
-
const patterns = [
|
|
66
|
-
`Read(./${relativePath})`,
|
|
67
|
-
`Read(${relativePath})`,
|
|
68
|
-
`Read(/**/${relativePath.split("/").pop()})`,
|
|
69
|
-
];
|
|
70
|
-
if (patterns.some((p) => cached.text.includes(p))) {
|
|
71
|
-
return true;
|
|
72
|
-
}
|
|
73
|
-
if (/"deny"\s*:\s*\[[^\]]*"Read"\s*[,\]]/.test(cached.text)) {
|
|
74
|
-
return true;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
67
|
export const envFileExposureRule = {
|
|
80
68
|
id: "security/env-file-exposure",
|
|
81
69
|
title: "Sensitive environment file may enter agent context",
|
|
@@ -93,19 +81,11 @@ export const envFileExposureRule = {
|
|
|
93
81
|
if (!kind) {
|
|
94
82
|
continue;
|
|
95
83
|
}
|
|
84
|
+
if (isSampleOrTestPath(file.relativePath)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// Templates document variable names; they are not agent-readiness findings.
|
|
96
88
|
if (kind === "template") {
|
|
97
|
-
findings.push({
|
|
98
|
-
ruleId: "security/env-file-exposure",
|
|
99
|
-
category: "security",
|
|
100
|
-
severity: "info",
|
|
101
|
-
title: "Environment template file present",
|
|
102
|
-
message: `Environment template file detected at ${file.relativePath}`,
|
|
103
|
-
whyItMatters: "Template env files are usually placeholders, but they can still document secret names. Filename classification only — contents were not inspected.",
|
|
104
|
-
recommendation: "Keep templates free of real credentials. Prefer documenting variable names without values.",
|
|
105
|
-
affectedAgents: [],
|
|
106
|
-
evidence: { path: file.relativePath, detail: "template" },
|
|
107
|
-
fixability: "review",
|
|
108
|
-
});
|
|
109
89
|
continue;
|
|
110
90
|
}
|
|
111
91
|
if (!agentsPresent) {
|
|
@@ -128,10 +108,15 @@ export const envFileExposureRule = {
|
|
|
128
108
|
}
|
|
129
109
|
let affected = agentsWithoutClearExclusion(context, file.relativePath);
|
|
130
110
|
if (affected.includes("claude-code")) {
|
|
131
|
-
if (await
|
|
111
|
+
if (await claudeDeniesPath(context, file.relativePath)) {
|
|
132
112
|
affected = affected.filter((a) => a !== "claude-code");
|
|
133
113
|
}
|
|
134
114
|
}
|
|
115
|
+
if (affected.includes("codex")) {
|
|
116
|
+
if (await codexDeniesPath(context, file.relativePath)) {
|
|
117
|
+
affected = affected.filter((a) => a !== "codex");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
135
120
|
if (affected.length === 0) {
|
|
136
121
|
continue;
|
|
137
122
|
}
|
|
@@ -142,7 +127,7 @@ export const envFileExposureRule = {
|
|
|
142
127
|
title: "Sensitive environment file may enter agent context",
|
|
143
128
|
message: `Sensitive environment file detected and no relevant exclusion was found for: ${file.relativePath}`,
|
|
144
129
|
whyItMatters: "Environment files frequently hold API keys and credentials. If readable by an AI coding agent, those values may be included in prompts or logs.",
|
|
145
|
-
recommendation: "Add an agent-specific exclusion (for example .cursorignore
|
|
130
|
+
recommendation: "Add an agent-specific exclusion (for example .cursorignore, a Claude Code Read deny rule, or a Codex filesystem deny), keep the file out of version control, and rotate any credentials that may have been exposed.",
|
|
146
131
|
affectedAgents: affected,
|
|
147
132
|
evidence: {
|
|
148
133
|
path: file.relativePath,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSampleOrTestPath } from "../path-kind.js";
|
|
1
2
|
const PRIVATE_KEY_PATTERNS = [
|
|
2
3
|
{ test: (b) => /^id_rsa$/i.test(b), label: "SSH private key" },
|
|
3
4
|
{ test: (b) => /^id_ed25519$/i.test(b), label: "SSH private key" },
|
|
@@ -40,6 +41,9 @@ export const privateKeyFileRule = {
|
|
|
40
41
|
const findings = [];
|
|
41
42
|
const affected = context.agents.filter((a) => a.detected || a.configured).map((a) => a.id);
|
|
42
43
|
for (const file of context.discovery.files) {
|
|
44
|
+
if (isSampleOrTestPath(file.relativePath)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
43
47
|
const base = file.relativePath.split("/").pop() ?? file.relativePath;
|
|
44
48
|
const label = classify(base);
|
|
45
49
|
if (!label) {
|
|
@@ -32,55 +32,61 @@ export class TextCache {
|
|
|
32
32
|
return blocked;
|
|
33
33
|
}
|
|
34
34
|
try {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
35
|
+
const handle = await fs.open(absolute, "r");
|
|
36
|
+
try {
|
|
37
|
+
const stat = await handle.stat();
|
|
38
|
+
if (!stat.isFile()) {
|
|
39
|
+
const result = {
|
|
40
|
+
relativePath: toPosixRelative(this.root, absolute),
|
|
41
|
+
text: null,
|
|
42
|
+
sizeBytes: 0,
|
|
43
|
+
empty: true,
|
|
44
|
+
binary: false,
|
|
45
|
+
error: "Not a regular file",
|
|
46
|
+
};
|
|
47
|
+
this.cache.set(relativePath, result);
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
const sizeBytes = Number(stat.size);
|
|
51
|
+
if (sizeBytes > this.maxBytes) {
|
|
52
|
+
const result = {
|
|
53
|
+
relativePath,
|
|
54
|
+
text: null,
|
|
55
|
+
sizeBytes,
|
|
56
|
+
empty: false,
|
|
57
|
+
binary: false,
|
|
58
|
+
error: "File exceeds max read size",
|
|
59
|
+
};
|
|
60
|
+
this.cache.set(relativePath, result);
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
const buffer = await handle.readFile();
|
|
64
|
+
if (looksBinary(buffer)) {
|
|
65
|
+
const result = {
|
|
66
|
+
relativePath,
|
|
67
|
+
text: null,
|
|
68
|
+
sizeBytes,
|
|
69
|
+
empty: sizeBytes === 0,
|
|
70
|
+
binary: true,
|
|
71
|
+
error: "Binary file skipped",
|
|
72
|
+
};
|
|
73
|
+
this.cache.set(relativePath, result);
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
const text = buffer.toString("utf8");
|
|
50
77
|
const result = {
|
|
51
78
|
relativePath,
|
|
52
|
-
text
|
|
79
|
+
text,
|
|
53
80
|
sizeBytes,
|
|
54
|
-
empty:
|
|
81
|
+
empty: text.trim().length === 0,
|
|
55
82
|
binary: false,
|
|
56
|
-
error: "File exceeds max read size",
|
|
57
83
|
};
|
|
58
84
|
this.cache.set(relativePath, result);
|
|
59
85
|
return result;
|
|
60
86
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const result = {
|
|
64
|
-
relativePath,
|
|
65
|
-
text: null,
|
|
66
|
-
sizeBytes,
|
|
67
|
-
empty: sizeBytes === 0,
|
|
68
|
-
binary: true,
|
|
69
|
-
error: "Binary file skipped",
|
|
70
|
-
};
|
|
71
|
-
this.cache.set(relativePath, result);
|
|
72
|
-
return result;
|
|
87
|
+
finally {
|
|
88
|
+
await handle.close();
|
|
73
89
|
}
|
|
74
|
-
const text = buffer.toString("utf8");
|
|
75
|
-
const result = {
|
|
76
|
-
relativePath,
|
|
77
|
-
text,
|
|
78
|
-
sizeBytes,
|
|
79
|
-
empty: text.trim().length === 0,
|
|
80
|
-
binary: false,
|
|
81
|
-
};
|
|
82
|
-
this.cache.set(relativePath, result);
|
|
83
|
-
return result;
|
|
84
90
|
}
|
|
85
91
|
catch (error) {
|
|
86
92
|
const message = error instanceof Error ? error.message : "Unable to read file";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Finding, FindingEvidence, Severity } from "../../types/index.js";
|
|
2
|
+
/** Stable subset of a finding for verify reports. */
|
|
3
|
+
export interface VerifyFindingRef {
|
|
4
|
+
id: string;
|
|
5
|
+
ruleId: string;
|
|
6
|
+
severity: Severity;
|
|
7
|
+
title: string;
|
|
8
|
+
message: string;
|
|
9
|
+
evidence?: FindingEvidence;
|
|
10
|
+
}
|
|
11
|
+
export interface FindingCompareResult {
|
|
12
|
+
fixed: VerifyFindingRef[];
|
|
13
|
+
remaining: VerifyFindingRef[];
|
|
14
|
+
new: VerifyFindingRef[];
|
|
15
|
+
unchanged: VerifyFindingRef[];
|
|
16
|
+
summary: {
|
|
17
|
+
fixed: number;
|
|
18
|
+
remaining: number;
|
|
19
|
+
new: number;
|
|
20
|
+
unchanged: number;
|
|
21
|
+
before: number;
|
|
22
|
+
after: number;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export declare function toVerifyFindingRef(finding: Finding): VerifyFindingRef;
|
|
26
|
+
/**
|
|
27
|
+
* Deterministic finding set comparison keyed by stable `Finding.id`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function compareFindings(before: readonly Finding[], after: readonly Finding[]): FindingCompareResult;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export function toVerifyFindingRef(finding) {
|
|
2
|
+
const ref = {
|
|
3
|
+
id: finding.id,
|
|
4
|
+
ruleId: finding.ruleId,
|
|
5
|
+
severity: finding.severity,
|
|
6
|
+
title: finding.title,
|
|
7
|
+
message: finding.message,
|
|
8
|
+
};
|
|
9
|
+
if (finding.evidence !== undefined) {
|
|
10
|
+
ref.evidence = { ...finding.evidence };
|
|
11
|
+
}
|
|
12
|
+
return ref;
|
|
13
|
+
}
|
|
14
|
+
function sortRefs(refs) {
|
|
15
|
+
return [...refs].sort((a, b) => a.id.localeCompare(b.id));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Deterministic finding set comparison keyed by stable `Finding.id`.
|
|
19
|
+
*/
|
|
20
|
+
export function compareFindings(before, after) {
|
|
21
|
+
const beforeById = new Map(before.map((f) => [f.id, f]));
|
|
22
|
+
const afterById = new Map(after.map((f) => [f.id, f]));
|
|
23
|
+
const fixed = [];
|
|
24
|
+
const remaining = [];
|
|
25
|
+
const newly = [];
|
|
26
|
+
for (const [id, finding] of beforeById) {
|
|
27
|
+
if (!afterById.has(id)) {
|
|
28
|
+
fixed.push(toVerifyFindingRef(finding));
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
remaining.push(toVerifyFindingRef(afterById.get(id)));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const [id, finding] of afterById) {
|
|
35
|
+
if (!beforeById.has(id)) {
|
|
36
|
+
newly.push(toVerifyFindingRef(finding));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const fixedSorted = sortRefs(fixed);
|
|
40
|
+
const remainingSorted = sortRefs(remaining);
|
|
41
|
+
const newSorted = sortRefs(newly);
|
|
42
|
+
return {
|
|
43
|
+
fixed: fixedSorted,
|
|
44
|
+
remaining: remainingSorted,
|
|
45
|
+
new: newSorted,
|
|
46
|
+
unchanged: remainingSorted,
|
|
47
|
+
summary: {
|
|
48
|
+
fixed: fixedSorted.length,
|
|
49
|
+
remaining: remainingSorted.length,
|
|
50
|
+
new: newSorted.length,
|
|
51
|
+
unchanged: remainingSorted.length,
|
|
52
|
+
before: before.length,
|
|
53
|
+
after: after.length,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Finding } from "../../types/index.js";
|
|
2
|
+
declare const DEFAULT_BASELINE_NAMES: readonly ["agentdoctor-report.json", ".agentdoctor-baseline.json"];
|
|
3
|
+
export interface LoadedBaseline {
|
|
4
|
+
path: string;
|
|
5
|
+
findings: Finding[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Load findings from a prior `scan --json` report (or a bare `{ findings: [...] }` file).
|
|
9
|
+
*/
|
|
10
|
+
export declare function loadBaselineFindings(baselinePath: string): Promise<LoadedBaseline>;
|
|
11
|
+
/**
|
|
12
|
+
* Resolve baseline path: explicit flag, else first default filename that exists under root.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveBaselinePath(root: string, explicitBaseline?: string): Promise<string | undefined>;
|
|
15
|
+
export { DEFAULT_BASELINE_NAMES };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const DEFAULT_BASELINE_NAMES = ["agentdoctor-report.json", ".agentdoctor-baseline.json"];
|
|
4
|
+
function isFindingLike(value) {
|
|
5
|
+
if (!value || typeof value !== "object") {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
const record = value;
|
|
9
|
+
return (typeof record.id === "string" &&
|
|
10
|
+
typeof record.ruleId === "string" &&
|
|
11
|
+
typeof record.severity === "string" &&
|
|
12
|
+
typeof record.title === "string" &&
|
|
13
|
+
typeof record.message === "string");
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Load findings from a prior `scan --json` report (or a bare `{ findings: [...] }` file).
|
|
17
|
+
*/
|
|
18
|
+
export async function loadBaselineFindings(baselinePath) {
|
|
19
|
+
const absolute = path.resolve(baselinePath);
|
|
20
|
+
let raw;
|
|
21
|
+
try {
|
|
22
|
+
raw = await fs.readFile(absolute, "utf8");
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new Error(`baseline not found or unreadable: ${absolute}`);
|
|
26
|
+
}
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(raw);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new Error(`baseline is not valid JSON: ${absolute}`);
|
|
33
|
+
}
|
|
34
|
+
if (!parsed || typeof parsed !== "object") {
|
|
35
|
+
throw new Error(`baseline JSON must be an object: ${absolute}`);
|
|
36
|
+
}
|
|
37
|
+
const findingsUnknown = parsed.findings;
|
|
38
|
+
if (!Array.isArray(findingsUnknown)) {
|
|
39
|
+
throw new Error(`baseline JSON must include a findings array: ${absolute}`);
|
|
40
|
+
}
|
|
41
|
+
const findings = [];
|
|
42
|
+
for (const item of findingsUnknown) {
|
|
43
|
+
if (!isFindingLike(item)) {
|
|
44
|
+
throw new Error(`baseline contains an invalid finding entry: ${absolute}`);
|
|
45
|
+
}
|
|
46
|
+
findings.push(item);
|
|
47
|
+
}
|
|
48
|
+
return { path: absolute, findings };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolve baseline path: explicit flag, else first default filename that exists under root.
|
|
52
|
+
*/
|
|
53
|
+
export async function resolveBaselinePath(root, explicitBaseline) {
|
|
54
|
+
if (explicitBaseline) {
|
|
55
|
+
return path.isAbsolute(explicitBaseline)
|
|
56
|
+
? explicitBaseline
|
|
57
|
+
: path.resolve(root, explicitBaseline);
|
|
58
|
+
}
|
|
59
|
+
for (const name of DEFAULT_BASELINE_NAMES) {
|
|
60
|
+
const candidate = path.join(root, name);
|
|
61
|
+
try {
|
|
62
|
+
await fs.access(candidate);
|
|
63
|
+
return candidate;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// try next
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
export { DEFAULT_BASELINE_NAMES };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Scores, ScanResult } from "../../types/index.js";
|
|
2
|
+
import { type FindingCompareResult } from "./compare.js";
|
|
3
|
+
export interface VerifyOptions {
|
|
4
|
+
cwd?: string;
|
|
5
|
+
/** Path to a prior scan JSON report. Defaults to agentdoctor-report.json if present. */
|
|
6
|
+
baselinePath?: string;
|
|
7
|
+
verbose?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface VerifyResult extends FindingCompareResult {
|
|
10
|
+
version: string;
|
|
11
|
+
repositoryRoot: string;
|
|
12
|
+
baselinePath: string;
|
|
13
|
+
after: ScanResult;
|
|
14
|
+
scores: Scores | null;
|
|
15
|
+
scoringAvailable: boolean;
|
|
16
|
+
timing: {
|
|
17
|
+
totalMs: number;
|
|
18
|
+
scanMs: number;
|
|
19
|
+
compareMs: number;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Re-scan the repository and compare against a prior scan JSON baseline.
|
|
24
|
+
* Reuses the public `scan()` pipeline — no duplicated rule engine.
|
|
25
|
+
*/
|
|
26
|
+
export declare function verify(options?: VerifyOptions): Promise<VerifyResult>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { PACKAGE_VERSION } from "../../constants.js";
|
|
2
|
+
import { scan } from "../scanner/scan.js";
|
|
3
|
+
import { compareFindings } from "./compare.js";
|
|
4
|
+
import { loadBaselineFindings, resolveBaselinePath } from "./load-baseline.js";
|
|
5
|
+
/**
|
|
6
|
+
* Re-scan the repository and compare against a prior scan JSON baseline.
|
|
7
|
+
* Reuses the public `scan()` pipeline — no duplicated rule engine.
|
|
8
|
+
*/
|
|
9
|
+
export async function verify(options = {}) {
|
|
10
|
+
const totalStarted = performance.now();
|
|
11
|
+
const cwd = options.cwd ?? process.cwd();
|
|
12
|
+
const resolvedBaseline = await resolveBaselinePath(cwd, options.baselinePath);
|
|
13
|
+
if (!resolvedBaseline) {
|
|
14
|
+
throw new Error("No verify baseline found. Run `agentdoctor scan --json > agentdoctor-report.json`, apply fixes, then re-run verify.");
|
|
15
|
+
}
|
|
16
|
+
const baseline = await loadBaselineFindings(resolvedBaseline);
|
|
17
|
+
const scanStarted = performance.now();
|
|
18
|
+
const after = await scan({
|
|
19
|
+
cwd,
|
|
20
|
+
verbose: options.verbose ?? false,
|
|
21
|
+
});
|
|
22
|
+
const scanMs = Math.round(performance.now() - scanStarted);
|
|
23
|
+
const compareStarted = performance.now();
|
|
24
|
+
const compared = compareFindings(baseline.findings, after.findings);
|
|
25
|
+
const compareMs = Math.round(performance.now() - compareStarted);
|
|
26
|
+
return {
|
|
27
|
+
version: PACKAGE_VERSION,
|
|
28
|
+
repositoryRoot: after.repository.root,
|
|
29
|
+
baselinePath: baseline.path,
|
|
30
|
+
...compared,
|
|
31
|
+
after,
|
|
32
|
+
scores: after.scores,
|
|
33
|
+
scoringAvailable: after.scoringAvailable,
|
|
34
|
+
timing: {
|
|
35
|
+
totalMs: Math.round(performance.now() - totalStarted),
|
|
36
|
+
scanMs,
|
|
37
|
+
compareMs,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
package/dist/discovery/files.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { DEFAULT_IGNORE_DIRECTORIES, DEFAULT_MAX_FILE_SIZE_BYTES } from "../constants.js";
|
|
4
4
|
import { isPathInsideRoot, toPosixRelative } from "../utils/path.js";
|
|
5
|
+
import { isLogLikePath } from "./log-like.js";
|
|
5
6
|
/**
|
|
6
7
|
* Walk the repository without blindly reading every file.
|
|
7
8
|
* Skips common generated/dependency directories and records permission errors.
|
|
@@ -70,14 +71,18 @@ export async function discoverFiles(options) {
|
|
|
70
71
|
}
|
|
71
72
|
try {
|
|
72
73
|
const stat = await fs.lstat(absolutePath);
|
|
73
|
-
|
|
74
|
+
const sizeBytes = Number(stat.size);
|
|
75
|
+
// Oversized binaries stay skipped (content reads already refuse them).
|
|
76
|
+
// Log/dump-like paths are kept as metadata so context/large-log-file
|
|
77
|
+
// can flag the worst offenders instead of silently missing them.
|
|
78
|
+
if (sizeBytes > maxFileSizeBytes && !isLogLikePath(relativePath)) {
|
|
74
79
|
filesSkippedOversized += 1;
|
|
75
80
|
continue;
|
|
76
81
|
}
|
|
77
82
|
files.push({
|
|
78
83
|
absolutePath,
|
|
79
84
|
relativePath,
|
|
80
|
-
sizeBytes
|
|
85
|
+
sizeBytes,
|
|
81
86
|
isSymlink,
|
|
82
87
|
});
|
|
83
88
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const LOG_LIKE = /\.(log|out|dump)$/i;
|
|
2
|
+
const HEAVY_NAME = /(^|\/)(debug|trace|coverage-final|chrome-devtools|heapdump)/i;
|
|
3
|
+
/** Path looks like a log/dump that can pollute agent context (size-only rules). */
|
|
4
|
+
export function isLogLikePath(relativePath) {
|
|
5
|
+
const base = relativePath.split("/").pop() ?? relativePath;
|
|
6
|
+
return LOG_LIKE.test(base) || HEAVY_NAME.test(relativePath);
|
|
7
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
export { scan } from "./core/scanner/scan.js";
|
|
2
|
+
export { verify } from "./core/verify/verify.js";
|
|
3
|
+
export { compareFindings } from "./core/verify/compare.js";
|
|
2
4
|
export { PACKAGE_VERSION } from "./constants.js";
|
|
3
5
|
export type { ScanOptions, ScanResult, RepositoryInfo, Finding, FindingsSummary, Scores, AgentPresence, AgentDetectionResult, AgentSecurityAnalysisMode, CliOptions, ExitCode, } from "./types/index.js";
|
|
6
|
+
export type { VerifyResult, VerifyOptions } from "./core/verify/verify.js";
|
|
7
|
+
export type { VerifyFindingRef, FindingCompareResult } from "./core/verify/compare.js";
|
|
4
8
|
export { EXIT_CODES } from "./types/index.js";
|
|
5
9
|
export { agentRegistry } from "./agents/registry.js";
|
|
6
10
|
export { detectAgents } from "./agents/detect-agents.js";
|
|
7
11
|
export { ruleRegistry, getRuleById } from "./core/rules/registry.js";
|
|
8
12
|
export { runRules } from "./core/rules/run-rules.js";
|
|
13
|
+
export { buildFixPlan } from "./core/fix/plan.js";
|
|
14
|
+
export { applyFixPlan } from "./core/fix/apply.js";
|
|
15
|
+
export { runFix } from "./core/fix/run.js";
|
|
16
|
+
export { evaluatePolicy, evaluateScanPolicy, evaluateVerifyPolicy, parseFailOnRules, parseSeverityGate, } from "./core/policy/evaluate.js";
|
|
17
|
+
export type { PolicyOptions, PolicyViolation, PolicyViolationCode, PolicyInput, } from "./core/policy/evaluate.js";
|
|
18
|
+
export type { FixPlan, FixAction, FixApplyResult } from "./core/fix/types.js";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
export { scan } from "./core/scanner/scan.js";
|
|
2
|
+
export { verify } from "./core/verify/verify.js";
|
|
3
|
+
export { compareFindings } from "./core/verify/compare.js";
|
|
2
4
|
export { PACKAGE_VERSION } from "./constants.js";
|
|
3
5
|
export { EXIT_CODES } from "./types/index.js";
|
|
4
6
|
export { agentRegistry } from "./agents/registry.js";
|
|
5
7
|
export { detectAgents } from "./agents/detect-agents.js";
|
|
6
8
|
export { ruleRegistry, getRuleById } from "./core/rules/registry.js";
|
|
7
9
|
export { runRules } from "./core/rules/run-rules.js";
|
|
10
|
+
export { buildFixPlan } from "./core/fix/plan.js";
|
|
11
|
+
export { applyFixPlan } from "./core/fix/apply.js";
|
|
12
|
+
export { runFix } from "./core/fix/run.js";
|
|
13
|
+
export { evaluatePolicy, evaluateScanPolicy, evaluateVerifyPolicy, parseFailOnRules, parseSeverityGate, } from "./core/policy/evaluate.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Finding } from "../../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* GitHub Actions workflow commands for file annotations.
|
|
4
|
+
* Emitted to stderr so `--json` stdout stays pure JSON.
|
|
5
|
+
*
|
|
6
|
+
* @see https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
|
|
7
|
+
*/
|
|
8
|
+
export declare function renderGithubAnnotations(findings: Finding[]): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions workflow commands for file annotations.
|
|
3
|
+
* Emitted to stderr so `--json` stdout stays pure JSON.
|
|
4
|
+
*
|
|
5
|
+
* @see https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
|
|
6
|
+
*/
|
|
7
|
+
export function renderGithubAnnotations(findings) {
|
|
8
|
+
const lines = [];
|
|
9
|
+
for (const finding of findings) {
|
|
10
|
+
lines.push(formatAnnotation(finding));
|
|
11
|
+
}
|
|
12
|
+
return lines.length > 0 ? `${lines.join("\n")}\n` : "";
|
|
13
|
+
}
|
|
14
|
+
function formatAnnotation(finding) {
|
|
15
|
+
const level = annotationLevel(finding.severity);
|
|
16
|
+
const properties = [];
|
|
17
|
+
const file = finding.evidence?.path?.replace(/\\/g, "/");
|
|
18
|
+
if (file) {
|
|
19
|
+
properties.push(`file=${escapeProperty(file)}`);
|
|
20
|
+
}
|
|
21
|
+
if (finding.evidence?.line !== undefined && Number.isFinite(finding.evidence.line)) {
|
|
22
|
+
properties.push(`line=${finding.evidence.line}`);
|
|
23
|
+
}
|
|
24
|
+
properties.push(`title=${escapeProperty(`${finding.ruleId}: ${finding.title}`)}`);
|
|
25
|
+
const message = escapeMessage(`${finding.message} (${finding.ruleId})`);
|
|
26
|
+
const props = properties.length > 0 ? ` ${properties.join(",")}` : "";
|
|
27
|
+
return `::${level}${props}::${message}`;
|
|
28
|
+
}
|
|
29
|
+
function annotationLevel(severity) {
|
|
30
|
+
return severity === "critical" ? "error" : severity === "warning" ? "warning" : "notice";
|
|
31
|
+
}
|
|
32
|
+
function escapeProperty(value) {
|
|
33
|
+
return value
|
|
34
|
+
.replace(/%/g, "%25")
|
|
35
|
+
.replace(/\r/g, "%0D")
|
|
36
|
+
.replace(/\n/g, "%0A")
|
|
37
|
+
.replace(/:/g, "%3A")
|
|
38
|
+
.replace(/,/g, "%2C");
|
|
39
|
+
}
|
|
40
|
+
function escapeMessage(value) {
|
|
41
|
+
return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
42
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PolicyViolation } from "../../core/policy/evaluate.js";
|
|
2
|
+
import type { Finding } from "../../types/index.js";
|
|
3
|
+
export interface GithubReportOptions {
|
|
4
|
+
summary?: boolean;
|
|
5
|
+
annotations?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function emitGithubReports(options: {
|
|
8
|
+
mode: "scan" | "verify";
|
|
9
|
+
findings: Finding[];
|
|
10
|
+
overallScore: number | null;
|
|
11
|
+
violations: PolicyViolation[];
|
|
12
|
+
verifySummary?: {
|
|
13
|
+
fixed: number;
|
|
14
|
+
remaining: number;
|
|
15
|
+
new: number;
|
|
16
|
+
unchanged: number;
|
|
17
|
+
};
|
|
18
|
+
summary?: boolean;
|
|
19
|
+
annotations?: boolean;
|
|
20
|
+
/** Override for tests */
|
|
21
|
+
stepSummaryPath?: string | null;
|
|
22
|
+
annotationsWrite?: (text: string) => void;
|
|
23
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { renderGithubAnnotations } from "./annotations.js";
|
|
3
|
+
import { renderGithubStepSummary } from "./summary.js";
|
|
4
|
+
export async function emitGithubReports(options) {
|
|
5
|
+
if (options.annotations) {
|
|
6
|
+
const text = renderGithubAnnotations(options.findings);
|
|
7
|
+
if (text.length > 0) {
|
|
8
|
+
const write = options.annotationsWrite ?? ((chunk) => process.stderr.write(chunk));
|
|
9
|
+
write(text);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
if (!options.summary) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const markdown = renderGithubStepSummary({
|
|
16
|
+
title: options.mode === "verify" ? "AgentDoctor Verify" : "AgentDoctor Scan",
|
|
17
|
+
overallScore: options.overallScore,
|
|
18
|
+
findings: options.findings,
|
|
19
|
+
violations: options.violations,
|
|
20
|
+
mode: options.mode,
|
|
21
|
+
...(options.verifySummary ? { verifySummary: options.verifySummary } : {}),
|
|
22
|
+
});
|
|
23
|
+
const summaryPath = options.stepSummaryPath !== undefined
|
|
24
|
+
? options.stepSummaryPath
|
|
25
|
+
: (process.env.GITHUB_STEP_SUMMARY ?? null);
|
|
26
|
+
if (summaryPath) {
|
|
27
|
+
await fs.appendFile(summaryPath, markdown, "utf8");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
process.stderr.write("Note: --summary set but GITHUB_STEP_SUMMARY is unset; skipping step summary write.\n");
|
|
31
|
+
}
|