@praneeth_54/agentdoctor 0.1.2-beta → 0.1.3-beta
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 +28 -1
- package/README.md +4 -4
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/core/rules/security/env-file-exposure.d.ts +6 -0
- package/dist/core/rules/security/env-file-exposure.js +78 -9
- package/dist/core/rules/security/private-key-file.js +5 -3
- package/dist/core/scanner/scan.js +6 -0
- package/dist/detectors/framework.d.ts +3 -0
- package/dist/detectors/framework.js +19 -9
- package/dist/detectors/language.d.ts +1 -0
- package/dist/detectors/language.js +7 -0
- package/dist/detectors/package-manager.d.ts +5 -0
- package/dist/detectors/package-manager.js +39 -1
- package/dist/detectors/project.js +68 -18
- package/dist/detectors/python-deps.d.ts +6 -0
- package/dist/detectors/python-deps.js +55 -0
- package/dist/index.d.ts +1 -1
- package/dist/reporters/json/report.js +1 -0
- package/dist/reporters/terminal/report.js +36 -10
- package/dist/types/index.d.ts +10 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
13
13
|
- Safe automatic fixes
|
|
14
14
|
- Packaged GitHub Action
|
|
15
15
|
|
|
16
|
+
## [0.1.3-beta] — 2026-07-29
|
|
17
|
+
|
|
18
|
+
Backward-compatible security-signal and detection-quality patch.
|
|
19
|
+
|
|
20
|
+
### Security
|
|
21
|
+
|
|
22
|
+
- Repositories without supported agent configuration no longer receive an unqualified clean result when relevant repository-risk findings exist
|
|
23
|
+
- Agent exposure and repository risk remain semantically distinct (`affectedAgents` is empty when exposure is not asserted)
|
|
24
|
+
- Additive `agentSecurityAnalysis` field: `full` | `limited`
|
|
25
|
+
- Environment templates (`.env.example`, `.env.sample`, `.env.template`, `.env.dist`) receive informational treatment
|
|
26
|
+
- Common environment backup filenames (`.env_backup`, `.env_old`, `.env_local`) receive conservative warning treatment
|
|
27
|
+
|
|
28
|
+
### Detection
|
|
29
|
+
|
|
30
|
+
- Prevent false Django detection from arbitrary nested `settings.py` files
|
|
31
|
+
- Detect nested FastAPI projects using dependency + `app/main.py` structure evidence
|
|
32
|
+
- Detect React from nested project manifests
|
|
33
|
+
- Prevent Poetry detection from generic non-Poetry `pyproject.toml` files (for example hatchling)
|
|
34
|
+
- Improve multi-stack terminal summaries for multi-project repositories
|
|
35
|
+
|
|
36
|
+
### Testing
|
|
37
|
+
|
|
38
|
+
- Added Excepta FastAPI/React/Flutter and security-semantics fixtures/tests
|
|
39
|
+
- Total test suite is now 122 tests
|
|
40
|
+
- Three real-world regression families validated (ProxyShield, Flutter/Laravel multi-app, Excepta)
|
|
41
|
+
|
|
16
42
|
## [0.1.2-beta] — 2026-07-29
|
|
17
43
|
|
|
18
44
|
Backward-compatible quality patch.
|
|
@@ -81,7 +107,8 @@ First public beta.
|
|
|
81
107
|
- Not a complete secret scanner
|
|
82
108
|
- Git “tracked secret” detection deferred
|
|
83
109
|
|
|
84
|
-
[Unreleased]: https://github.com/pranee54/AgentDoctor/compare/v0.1.
|
|
110
|
+
[Unreleased]: https://github.com/pranee54/AgentDoctor/compare/v0.1.3-beta...HEAD
|
|
111
|
+
[0.1.3-beta]: https://github.com/pranee54/AgentDoctor/releases/tag/v0.1.3-beta
|
|
85
112
|
[0.1.2-beta]: https://github.com/pranee54/AgentDoctor/releases/tag/v0.1.2-beta
|
|
86
113
|
[0.1.1-beta]: https://github.com/pranee54/AgentDoctor/releases/tag/v0.1.1-beta
|
|
87
114
|
[0.1.0-beta]: https://github.com/pranee54/AgentDoctor/releases/tag/v0.1.0-beta
|
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ npx @praneeth_54/agentdoctor
|
|
|
29
29
|
```text
|
|
30
30
|
$ npx @praneeth_54/agentdoctor
|
|
31
31
|
|
|
32
|
-
AgentDoctor v0.1.
|
|
32
|
+
AgentDoctor v0.1.3-beta
|
|
33
33
|
|
|
34
34
|
Repository
|
|
35
35
|
Framework: Next.js
|
|
@@ -88,13 +88,13 @@ AgentDoctor analyzes configuration files. It does not run agents or edit your pr
|
|
|
88
88
|
### One-shot
|
|
89
89
|
|
|
90
90
|
```bash
|
|
91
|
-
npx @praneeth_54/agentdoctor@0.1.
|
|
91
|
+
npx @praneeth_54/agentdoctor@0.1.3-beta
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
### Global (optional)
|
|
95
95
|
|
|
96
96
|
```bash
|
|
97
|
-
npm install -g @praneeth_54/agentdoctor@0.1.
|
|
97
|
+
npm install -g @praneeth_54/agentdoctor@0.1.3-beta
|
|
98
98
|
agentdoctor
|
|
99
99
|
```
|
|
100
100
|
|
|
@@ -225,7 +225,7 @@ npx @praneeth_54/agentdoctor --json
|
|
|
225
225
|
|
|
226
226
|
```json
|
|
227
227
|
{
|
|
228
|
-
"version": "0.1.
|
|
228
|
+
"version": "0.1.3-beta",
|
|
229
229
|
"repository": {
|
|
230
230
|
"primaryFramework": "nextjs",
|
|
231
231
|
"primaryLanguage": "typescript",
|
package/dist/constants.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const PACKAGE_VERSION = "0.1.
|
|
1
|
+
export declare const PACKAGE_VERSION = "0.1.3-beta";
|
|
2
2
|
export declare const DEFAULT_MAX_FILE_SIZE_BYTES: number;
|
|
3
3
|
/** Directories skipped during normal discovery (unless a rule needs them later). */
|
|
4
4
|
export declare const DEFAULT_IGNORE_DIRECTORIES: Set<string>;
|
package/dist/constants.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const PACKAGE_VERSION = "0.1.
|
|
1
|
+
export const PACKAGE_VERSION = "0.1.3-beta";
|
|
2
2
|
export const DEFAULT_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; // 2 MiB
|
|
3
3
|
/** Directories skipped during normal discovery (unless a rule needs them later). */
|
|
4
4
|
export const DEFAULT_IGNORE_DIRECTORIES = new Set([
|
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
import type { RuleDefinition } from "../types.js";
|
|
2
|
+
export type EnvFileKind = "runtime" | "template" | "backup";
|
|
3
|
+
/**
|
|
4
|
+
* Classify environment-like filenames. Precision over recall.
|
|
5
|
+
* Does not inspect file contents.
|
|
6
|
+
*/
|
|
7
|
+
export declare function classifyEnvBasename(base: string): EnvFileKind | null;
|
|
2
8
|
export declare const envFileExposureRule: RuleDefinition;
|
|
@@ -1,7 +1,36 @@
|
|
|
1
|
-
const
|
|
1
|
+
const TEMPLATE_BASENAMES = new Set([".env.example", ".env.sample", ".env.template", ".env.dist"]);
|
|
2
|
+
const BACKUP_BASENAMES = new Set([".env_backup", ".env_old", ".env_local"]);
|
|
3
|
+
/**
|
|
4
|
+
* Classify environment-like filenames. Precision over recall.
|
|
5
|
+
* Does not inspect file contents.
|
|
6
|
+
*/
|
|
7
|
+
export function classifyEnvBasename(base) {
|
|
8
|
+
const normalized = base.trim();
|
|
9
|
+
if (!normalized.toLowerCase().startsWith(".env")) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const lower = normalized.toLowerCase();
|
|
13
|
+
if (TEMPLATE_BASENAMES.has(lower)) {
|
|
14
|
+
return "template";
|
|
15
|
+
}
|
|
16
|
+
if (BACKUP_BASENAMES.has(lower)) {
|
|
17
|
+
return "backup";
|
|
18
|
+
}
|
|
19
|
+
if (lower === ".env") {
|
|
20
|
+
return "runtime";
|
|
21
|
+
}
|
|
22
|
+
// .env.<name> but not templates above
|
|
23
|
+
if (/^\.env\.[A-Za-z0-9_.-]+$/.test(normalized)) {
|
|
24
|
+
return "runtime";
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
2
28
|
function isEnvFile(relativePath) {
|
|
3
29
|
const base = relativePath.split("/").pop() ?? relativePath;
|
|
4
|
-
return
|
|
30
|
+
return classifyEnvBasename(base);
|
|
31
|
+
}
|
|
32
|
+
function hasConfiguredOrDetectedAgents(context) {
|
|
33
|
+
return context.agents.some((a) => a.detected || a.configured);
|
|
5
34
|
}
|
|
6
35
|
function agentsWithoutClearExclusion(context, relativePath) {
|
|
7
36
|
const affected = [];
|
|
@@ -50,16 +79,53 @@ async function claudeDeniesRead(context, relativePath) {
|
|
|
50
79
|
export const envFileExposureRule = {
|
|
51
80
|
id: "security/env-file-exposure",
|
|
52
81
|
title: "Sensitive environment file may enter agent context",
|
|
53
|
-
description: "Detects repository .env files
|
|
82
|
+
description: "Detects repository .env files as repository risk and, when agents are configured, possible agent context exposure.",
|
|
54
83
|
category: "security",
|
|
55
84
|
severity: "critical",
|
|
56
85
|
fixability: "review",
|
|
57
|
-
rationale: "Environment files often contain credentials.
|
|
58
|
-
recommendation: "
|
|
86
|
+
rationale: "Environment files often contain credentials. Repository presence is a risk; agent readability can add exposure.",
|
|
87
|
+
recommendation: "Keep runtime .env files out of version control, exclude them from AI agent context, and rotate credentials if exposed.",
|
|
59
88
|
async check(context) {
|
|
60
89
|
const findings = [];
|
|
61
|
-
const
|
|
62
|
-
for (const file of
|
|
90
|
+
const agentsPresent = hasConfiguredOrDetectedAgents(context);
|
|
91
|
+
for (const file of context.discovery.files) {
|
|
92
|
+
const kind = isEnvFile(file.relativePath);
|
|
93
|
+
if (!kind) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
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
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!agentsPresent) {
|
|
112
|
+
findings.push({
|
|
113
|
+
ruleId: "security/env-file-exposure",
|
|
114
|
+
category: "security",
|
|
115
|
+
severity: kind === "backup" ? "warning" : "critical",
|
|
116
|
+
title: "Sensitive environment file present in repository",
|
|
117
|
+
message: `Sensitive environment file present in the repository: ${file.relativePath}`,
|
|
118
|
+
whyItMatters: "No supported coding-agent configuration was detected, so agent-specific exposure was not asserted. The file is still high-risk repository material if tracked or shared.",
|
|
119
|
+
recommendation: "Keep runtime environment files out of version control and rotate credentials if the file may have been shared.",
|
|
120
|
+
affectedAgents: [],
|
|
121
|
+
evidence: {
|
|
122
|
+
path: file.relativePath,
|
|
123
|
+
detail: kind === "backup" ? "backup-no-agent" : "runtime-no-agent",
|
|
124
|
+
},
|
|
125
|
+
fixability: "review",
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
63
129
|
let affected = agentsWithoutClearExclusion(context, file.relativePath);
|
|
64
130
|
if (affected.includes("claude-code")) {
|
|
65
131
|
if (await claudeDeniesRead(context, file.relativePath)) {
|
|
@@ -72,13 +138,16 @@ export const envFileExposureRule = {
|
|
|
72
138
|
findings.push({
|
|
73
139
|
ruleId: "security/env-file-exposure",
|
|
74
140
|
category: "security",
|
|
75
|
-
severity: "critical",
|
|
141
|
+
severity: kind === "backup" ? "warning" : "critical",
|
|
76
142
|
title: "Sensitive environment file may enter agent context",
|
|
77
143
|
message: `Sensitive environment file detected and no relevant exclusion was found for: ${file.relativePath}`,
|
|
78
144
|
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.",
|
|
79
145
|
recommendation: "Add an agent-specific exclusion (for example .cursorignore or a Claude Code Read deny rule), keep the file out of version control, and rotate any credentials that may have been exposed.",
|
|
80
146
|
affectedAgents: affected,
|
|
81
|
-
evidence: {
|
|
147
|
+
evidence: {
|
|
148
|
+
path: file.relativePath,
|
|
149
|
+
detail: kind === "backup" ? "backup" : "runtime",
|
|
150
|
+
},
|
|
82
151
|
fixability: "review",
|
|
83
152
|
});
|
|
84
153
|
}
|
|
@@ -39,22 +39,24 @@ export const privateKeyFileRule = {
|
|
|
39
39
|
async check(context) {
|
|
40
40
|
const findings = [];
|
|
41
41
|
const affected = context.agents.filter((a) => a.detected || a.configured).map((a) => a.id);
|
|
42
|
-
const agents = affected.length > 0 ? affected : ["cursor", "claude-code", "codex"];
|
|
43
42
|
for (const file of context.discovery.files) {
|
|
44
43
|
const base = file.relativePath.split("/").pop() ?? file.relativePath;
|
|
45
44
|
const label = classify(base);
|
|
46
45
|
if (!label) {
|
|
47
46
|
continue;
|
|
48
47
|
}
|
|
48
|
+
const agentsPresent = affected.length > 0;
|
|
49
49
|
findings.push({
|
|
50
50
|
ruleId: "security/private-key-file",
|
|
51
51
|
category: "security",
|
|
52
52
|
severity: "critical",
|
|
53
53
|
title: "Private key or credential file present in repository",
|
|
54
54
|
message: `Possible ${label} detected at ${file.relativePath}`,
|
|
55
|
-
whyItMatters:
|
|
55
|
+
whyItMatters: agentsPresent
|
|
56
|
+
? "Credential material in the working tree can be read by AI coding agents and may leak into model context or logs. Filename heuristics only — contents were not inspected."
|
|
57
|
+
: "Credential material in the working tree is high-risk repository content. No supported coding-agent configuration was detected, so agent-specific exposure was not asserted. Filename heuristics only — contents were not inspected.",
|
|
56
58
|
recommendation: "Remove the file from the repository, rotate the credential, add ignore rules, and never commit replacements.",
|
|
57
|
-
affectedAgents:
|
|
59
|
+
affectedAgents: affected,
|
|
58
60
|
evidence: { path: file.relativePath, detail: label },
|
|
59
61
|
fixability: "manual",
|
|
60
62
|
});
|
|
@@ -46,6 +46,11 @@ export async function scan(options = {}) {
|
|
|
46
46
|
for (const error of ruleErrors) {
|
|
47
47
|
warnings.push(sanitizeTerminalText(error));
|
|
48
48
|
}
|
|
49
|
+
const agentsPresent = agents.some((a) => a.detected || a.configured);
|
|
50
|
+
const agentSecurityAnalysis = agentsPresent ? "full" : "limited";
|
|
51
|
+
if (agentSecurityAnalysis === "limited") {
|
|
52
|
+
warnings.push("No supported coding-agent configuration detected; agent-specific security exposure checks are limited.");
|
|
53
|
+
}
|
|
49
54
|
return {
|
|
50
55
|
version: PACKAGE_VERSION,
|
|
51
56
|
repository,
|
|
@@ -54,6 +59,7 @@ export async function scan(options = {}) {
|
|
|
54
59
|
summary,
|
|
55
60
|
scores: null,
|
|
56
61
|
scoringAvailable: false,
|
|
62
|
+
agentSecurityAnalysis,
|
|
57
63
|
timing: {
|
|
58
64
|
discoveryMs: discovery.elapsedMs,
|
|
59
65
|
detectionMs,
|
|
@@ -3,6 +3,8 @@ export interface FrameworkDetectionInput {
|
|
|
3
3
|
relativePaths: string[];
|
|
4
4
|
packageJsonDependencies?: Record<string, string>;
|
|
5
5
|
composerJsonRequire?: Record<string, string>;
|
|
6
|
+
/** Lowercase dependency/package names discovered from Python manifests. */
|
|
7
|
+
pythonDependencyNames?: string[];
|
|
6
8
|
pubspecName?: string;
|
|
7
9
|
}
|
|
8
10
|
export interface FrameworkDetectionResult {
|
|
@@ -15,3 +17,4 @@ export interface FrameworkDetectionResult {
|
|
|
15
17
|
*/
|
|
16
18
|
export declare function detectFrameworks(input: FrameworkDetectionInput): FrameworkDetectionResult;
|
|
17
19
|
export declare function formatFramework(framework: FrameworkId): string;
|
|
20
|
+
export declare function formatFrameworks(ids: FrameworkId[]): string;
|
|
@@ -4,6 +4,9 @@ function hasDep(deps, name) {
|
|
|
4
4
|
function hasFile(paths, ...names) {
|
|
5
5
|
return names.some((name) => paths.has(name.toLowerCase()));
|
|
6
6
|
}
|
|
7
|
+
function hasPythonDep(names, dep) {
|
|
8
|
+
return Boolean(names?.includes(dep.toLowerCase()));
|
|
9
|
+
}
|
|
7
10
|
/**
|
|
8
11
|
* Detect frameworks from config markers and dependency names.
|
|
9
12
|
* Does not fail when nothing matches — returns unknown.
|
|
@@ -18,6 +21,7 @@ export function detectFrameworks(input) {
|
|
|
18
21
|
...(input.packageJsonDependencies ?? {}),
|
|
19
22
|
};
|
|
20
23
|
const composer = input.composerJsonRequire ?? {};
|
|
24
|
+
const pythonDeps = input.pythonDependencyNames ?? [];
|
|
21
25
|
const detected = [];
|
|
22
26
|
const isNext = hasFile(basenames, "next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs") ||
|
|
23
27
|
hasDep(deps, "next");
|
|
@@ -63,15 +67,12 @@ export function detectFrameworks(input) {
|
|
|
63
67
|
!isVue &&
|
|
64
68
|
!isSvelte &&
|
|
65
69
|
!isReact &&
|
|
66
|
-
(hasFile(basenames, "package.json") || hasDep(deps, "express") === false) &&
|
|
67
70
|
hasFile(basenames, "package.json")) {
|
|
68
|
-
// Generic Node.js project with package.json but no web framework
|
|
69
71
|
if (!detected.includes("nodejs")) {
|
|
70
72
|
detected.push("nodejs");
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
75
|
if (hasFile(basenames, "pubspec.yaml") || input.pubspecName !== undefined) {
|
|
74
|
-
// Flutter: pubspec presence implies Flutter/Dart project context for detection
|
|
75
76
|
if (hasFile(pathSet, "pubspec.yaml") || basenames.has("pubspec.yaml")) {
|
|
76
77
|
detected.push("flutter");
|
|
77
78
|
}
|
|
@@ -79,17 +80,19 @@ export function detectFrameworks(input) {
|
|
|
79
80
|
if (hasDep(composer, "laravel/framework") || hasFile(basenames, "artisan")) {
|
|
80
81
|
detected.push("laravel");
|
|
81
82
|
}
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
const isDjango = hasFile(basenames, "manage.py") ||
|
|
84
|
+
hasPythonDep(pythonDeps, "django") ||
|
|
85
|
+
[...pathSet].some((p) => p.endsWith("/requirements-django.txt"));
|
|
86
|
+
if (isDjango) {
|
|
84
87
|
detected.push("django");
|
|
85
88
|
}
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
const hasFastApiDep = hasPythonDep(pythonDeps, "fastapi") ||
|
|
90
|
+
[...pathSet].some((p) => p.includes("fastapi") || p.endsWith("requirements-fastapi.txt"));
|
|
91
|
+
const hasFastApiAppLayout = [...pathSet].some((p) => p === "app/main.py" || p.endsWith("/app/main.py"));
|
|
92
|
+
if (hasFastApiDep && hasFastApiAppLayout) {
|
|
88
93
|
detected.push("fastapi");
|
|
89
94
|
}
|
|
90
|
-
// Deduplicate while preserving order
|
|
91
95
|
const unique = [...new Set(detected)];
|
|
92
|
-
// Priority for primary framework
|
|
93
96
|
const priority = [
|
|
94
97
|
"nextjs",
|
|
95
98
|
"nuxt",
|
|
@@ -146,3 +149,10 @@ export function formatFramework(framework) {
|
|
|
146
149
|
return "Unknown";
|
|
147
150
|
}
|
|
148
151
|
}
|
|
152
|
+
export function formatFrameworks(ids) {
|
|
153
|
+
const unique = [...new Set(ids)].filter((id) => id !== "unknown");
|
|
154
|
+
if (unique.length === 0) {
|
|
155
|
+
return "Unknown";
|
|
156
|
+
}
|
|
157
|
+
return unique.map((id) => formatFramework(id)).join(", ");
|
|
158
|
+
}
|
|
@@ -12,3 +12,4 @@ export interface LanguageDetectionResult {
|
|
|
12
12
|
*/
|
|
13
13
|
export declare function detectLanguages(input: LanguageDetectionInput): LanguageDetectionResult;
|
|
14
14
|
export declare function formatLanguage(language: LanguageId): string;
|
|
15
|
+
export declare function formatLanguages(ids: LanguageId[]): string;
|
|
@@ -120,3 +120,10 @@ export function formatLanguage(language) {
|
|
|
120
120
|
return "Unknown";
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
|
+
export function formatLanguages(ids) {
|
|
124
|
+
const unique = [...new Set(ids)].filter((id) => id !== "unknown");
|
|
125
|
+
if (unique.length === 0) {
|
|
126
|
+
return "Unknown";
|
|
127
|
+
}
|
|
128
|
+
return unique.map((id) => formatLanguage(id)).join(", ");
|
|
129
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { PackageManagerId } from "../types/index.js";
|
|
2
2
|
export interface PackageManagerDetectionInput {
|
|
3
3
|
relativePaths: string[];
|
|
4
|
+
/** True when a pyproject.toml contains a [tool.poetry] table. */
|
|
5
|
+
poetryToolDetected?: boolean;
|
|
6
|
+
/** True when a non-Poetry pyproject.toml (or similar) indicates a Python project. */
|
|
7
|
+
genericPyprojectDetected?: boolean;
|
|
4
8
|
}
|
|
5
9
|
export interface PackageManagerDetectionResult {
|
|
6
10
|
packageManagers: PackageManagerId[];
|
|
@@ -9,6 +13,7 @@ export interface PackageManagerDetectionResult {
|
|
|
9
13
|
/**
|
|
10
14
|
* Detect package managers from lockfiles and manifests.
|
|
11
15
|
* Primary selection prefers root-level evidence and avoids inventing certainty for mixed ecosystems.
|
|
16
|
+
* Poetry requires poetry.lock and/or [tool.poetry] — not bare pyproject.toml.
|
|
12
17
|
*/
|
|
13
18
|
export declare function detectPackageManagers(input: PackageManagerDetectionInput): PackageManagerDetectionResult;
|
|
14
19
|
export declare function formatPackageManager(id: PackageManagerId): string;
|
|
@@ -9,7 +9,7 @@ const MARKERS = [
|
|
|
9
9
|
},
|
|
10
10
|
{ id: "composer", files: ["composer.lock", "composer.json"], lockfiles: ["composer.lock"] },
|
|
11
11
|
{ id: "pub", files: ["pubspec.yaml", "pubspec.lock"], lockfiles: ["pubspec.lock"] },
|
|
12
|
-
{ id: "poetry", files: ["poetry.lock"
|
|
12
|
+
{ id: "poetry", files: ["poetry.lock"], lockfiles: ["poetry.lock"] },
|
|
13
13
|
{
|
|
14
14
|
id: "pip",
|
|
15
15
|
files: ["requirements.txt", "Pipfile", "Pipfile.lock"],
|
|
@@ -33,6 +33,7 @@ function isRootPath(relativePath) {
|
|
|
33
33
|
/**
|
|
34
34
|
* Detect package managers from lockfiles and manifests.
|
|
35
35
|
* Primary selection prefers root-level evidence and avoids inventing certainty for mixed ecosystems.
|
|
36
|
+
* Poetry requires poetry.lock and/or [tool.poetry] — not bare pyproject.toml.
|
|
36
37
|
*/
|
|
37
38
|
export function detectPackageManagers(input) {
|
|
38
39
|
const paths = input.relativePaths;
|
|
@@ -64,6 +65,43 @@ export function detectPackageManagers(input) {
|
|
|
64
65
|
}
|
|
65
66
|
continue;
|
|
66
67
|
}
|
|
68
|
+
if (marker.id === "poetry") {
|
|
69
|
+
const hasLock = basenames.has("poetry.lock");
|
|
70
|
+
const hasTool = input.poetryToolDetected === true;
|
|
71
|
+
if (!hasLock && !hasTool) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
found.push("poetry");
|
|
75
|
+
if (hasLock || (hasTool && rootBasenames.has("pyproject.toml"))) {
|
|
76
|
+
rootFound.push("poetry");
|
|
77
|
+
}
|
|
78
|
+
if (hasLock) {
|
|
79
|
+
lockFound.push("poetry");
|
|
80
|
+
if (rootBasenames.has("poetry.lock")) {
|
|
81
|
+
rootLockFound.push("poetry");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (marker.id === "pip") {
|
|
87
|
+
const hasMarker = marker.files.some((f) => basenames.has(f.toLowerCase()));
|
|
88
|
+
const hasGenericPyproject = input.genericPyprojectDetected === true;
|
|
89
|
+
if (!hasMarker && !hasGenericPyproject) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
found.push("pip");
|
|
93
|
+
if (marker.files.some((f) => rootBasenames.has(f.toLowerCase())) ||
|
|
94
|
+
(hasGenericPyproject && rootBasenames.has("pyproject.toml"))) {
|
|
95
|
+
rootFound.push("pip");
|
|
96
|
+
}
|
|
97
|
+
if (marker.lockfiles.some((f) => basenames.has(f.toLowerCase()))) {
|
|
98
|
+
lockFound.push("pip");
|
|
99
|
+
}
|
|
100
|
+
if (marker.lockfiles.some((f) => rootBasenames.has(f.toLowerCase()))) {
|
|
101
|
+
rootLockFound.push("pip");
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
67
105
|
const hasAny = marker.files.some((f) => basenames.has(f.toLowerCase()));
|
|
68
106
|
if (!hasAny) {
|
|
69
107
|
continue;
|
|
@@ -5,8 +5,13 @@ import { detectFrameworks } from "./framework.js";
|
|
|
5
5
|
import { detectLanguages } from "./language.js";
|
|
6
6
|
import { detectMonorepo } from "./monorepo.js";
|
|
7
7
|
import { detectPackageManagers } from "./package-manager.js";
|
|
8
|
-
import {
|
|
8
|
+
import { extractPythonDependencyNames, pyprojectHasPoetryTool } from "./python-deps.js";
|
|
9
|
+
import { isDirectory, readJsonFile, readTextFile } from "../utils/fs.js";
|
|
9
10
|
import { resolveRepoRoot } from "../utils/path.js";
|
|
11
|
+
function basename(relativePath) {
|
|
12
|
+
const parts = relativePath.split("/");
|
|
13
|
+
return parts[parts.length - 1] ?? relativePath;
|
|
14
|
+
}
|
|
10
15
|
/**
|
|
11
16
|
* Discover files and detect repository characteristics.
|
|
12
17
|
*/
|
|
@@ -28,32 +33,77 @@ export async function detectProject(cwd, maxFileSizeBytes = DEFAULT_MAX_FILE_SIZ
|
|
|
28
33
|
}
|
|
29
34
|
diagnostics.push(`Permission/read issue: ${err}`);
|
|
30
35
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const mergedPackageDeps = {};
|
|
37
|
+
let rootWorkspaces;
|
|
38
|
+
const packageJsonPaths = relativePaths.filter((p) => basename(p).toLowerCase() === "package.json");
|
|
39
|
+
for (const relative of packageJsonPaths) {
|
|
40
|
+
const absolute = path.join(root, relative);
|
|
41
|
+
const packageResult = await readJsonFile(absolute, maxFileSizeBytes);
|
|
42
|
+
if (!packageResult.ok) {
|
|
43
|
+
if (relative === "package.json") {
|
|
44
|
+
diagnostics.push(`Malformed package.json: ${packageResult.error}`);
|
|
45
|
+
}
|
|
46
|
+
continue;
|
|
38
47
|
}
|
|
39
|
-
|
|
40
|
-
|
|
48
|
+
Object.assign(mergedPackageDeps, packageResult.data.dependencies ?? {}, packageResult.data.devDependencies ?? {}, packageResult.data.peerDependencies ?? {});
|
|
49
|
+
if (relative === "package.json") {
|
|
50
|
+
rootWorkspaces = packageResult.data.workspaces;
|
|
41
51
|
}
|
|
42
52
|
}
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
const composerRequire = {};
|
|
54
|
+
const composerPaths = relativePaths.filter((p) => basename(p).toLowerCase() === "composer.json");
|
|
55
|
+
for (const relative of composerPaths) {
|
|
56
|
+
const absolute = path.join(root, relative);
|
|
57
|
+
const result = await readJsonFile(absolute, maxFileSizeBytes);
|
|
58
|
+
if (!result.ok) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
Object.assign(composerRequire, result.data.require ?? {}, result.data["require-dev"] ?? {});
|
|
62
|
+
}
|
|
63
|
+
const pythonDependencyNames = new Set();
|
|
64
|
+
let poetryToolDetected = false;
|
|
65
|
+
let genericPyprojectDetected = false;
|
|
66
|
+
for (const relative of relativePaths) {
|
|
67
|
+
const base = basename(relative).toLowerCase();
|
|
68
|
+
if (base !== "pyproject.toml" &&
|
|
69
|
+
base !== "requirements.txt" &&
|
|
70
|
+
!/^requirements(-[a-z0-9_-]+)?\.txt$/i.test(base)) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const text = await readTextFile(path.join(root, relative), maxFileSizeBytes);
|
|
74
|
+
if (!text) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
for (const name of extractPythonDependencyNames(text)) {
|
|
78
|
+
pythonDependencyNames.add(name);
|
|
79
|
+
}
|
|
80
|
+
if (base === "pyproject.toml") {
|
|
81
|
+
if (pyprojectHasPoetryTool(text)) {
|
|
82
|
+
poetryToolDetected = true;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
genericPyprojectDetected = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (relativePaths.some((p) => basename(p).toLowerCase() === "pipfile")) {
|
|
90
|
+
genericPyprojectDetected = true;
|
|
91
|
+
}
|
|
48
92
|
const languages = detectLanguages({ relativePaths });
|
|
49
93
|
const frameworks = detectFrameworks({
|
|
50
94
|
relativePaths,
|
|
51
|
-
packageJsonDependencies:
|
|
95
|
+
packageJsonDependencies: mergedPackageDeps,
|
|
96
|
+
composerJsonRequire: composerRequire,
|
|
97
|
+
pythonDependencyNames: [...pythonDependencyNames],
|
|
98
|
+
});
|
|
99
|
+
const packageManagers = detectPackageManagers({
|
|
100
|
+
relativePaths,
|
|
101
|
+
poetryToolDetected,
|
|
102
|
+
genericPyprojectDetected,
|
|
52
103
|
});
|
|
53
|
-
const packageManagers = detectPackageManagers({ relativePaths });
|
|
54
104
|
const monorepo = detectMonorepo({
|
|
55
105
|
relativePaths,
|
|
56
|
-
packageJsonWorkspaces:
|
|
106
|
+
packageJsonWorkspaces: rootWorkspaces,
|
|
57
107
|
});
|
|
58
108
|
const repository = {
|
|
59
109
|
root,
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight Python dependency name extraction from manifests.
|
|
3
|
+
* Filename/table semantics only — does not evaluate environments.
|
|
4
|
+
*/
|
|
5
|
+
export declare function pyprojectHasPoetryTool(text: string): boolean;
|
|
6
|
+
export declare function extractPythonDependencyNames(text: string): string[];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight Python dependency name extraction from manifests.
|
|
3
|
+
* Filename/table semantics only — does not evaluate environments.
|
|
4
|
+
*/
|
|
5
|
+
const SKIP_KEYS = new Set([
|
|
6
|
+
"python",
|
|
7
|
+
"package-mode",
|
|
8
|
+
"packages",
|
|
9
|
+
"include",
|
|
10
|
+
"exclude",
|
|
11
|
+
"readme",
|
|
12
|
+
"homepage",
|
|
13
|
+
"repository",
|
|
14
|
+
"documentation",
|
|
15
|
+
"keywords",
|
|
16
|
+
"classifiers",
|
|
17
|
+
"license",
|
|
18
|
+
"authors",
|
|
19
|
+
"maintainers",
|
|
20
|
+
"dependencies",
|
|
21
|
+
"dev-dependencies",
|
|
22
|
+
"group",
|
|
23
|
+
]);
|
|
24
|
+
export function pyprojectHasPoetryTool(text) {
|
|
25
|
+
return /\[tool\.poetry(?:\.|\])/m.test(text);
|
|
26
|
+
}
|
|
27
|
+
export function extractPythonDependencyNames(text) {
|
|
28
|
+
const names = new Set();
|
|
29
|
+
for (const match of text.matchAll(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*=\s*["'{[]/gm)) {
|
|
30
|
+
const name = (match[1] ?? "").toLowerCase().replace(/_/g, "-");
|
|
31
|
+
if (name && !SKIP_KEYS.has(name) && !name.startsWith("tool.")) {
|
|
32
|
+
names.add(name);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (const match of text.matchAll(/["']([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?(?:\s*[><=! ~^,;]|["'])/g)) {
|
|
36
|
+
const name = (match[1] ?? "").toLowerCase().replace(/_/g, "-");
|
|
37
|
+
if (name && !SKIP_KEYS.has(name)) {
|
|
38
|
+
names.add(name);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
42
|
+
const line = rawLine.trim();
|
|
43
|
+
if (!line || line.startsWith("#") || line.startsWith("-")) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const name = line
|
|
47
|
+
.split(/[><=![;@\s]/)[0]
|
|
48
|
+
?.toLowerCase()
|
|
49
|
+
.replace(/_/g, "-");
|
|
50
|
+
if (name && /^[a-z0-9][a-z0-9._-]*$/.test(name) && !SKIP_KEYS.has(name)) {
|
|
51
|
+
names.add(name);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return [...names];
|
|
55
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { scan } from "./core/scanner/scan.js";
|
|
2
2
|
export { PACKAGE_VERSION } from "./constants.js";
|
|
3
|
-
export type { ScanOptions, ScanResult, RepositoryInfo, Finding, FindingsSummary, Scores, AgentPresence, AgentDetectionResult, CliOptions, ExitCode, } from "./types/index.js";
|
|
3
|
+
export type { ScanOptions, ScanResult, RepositoryInfo, Finding, FindingsSummary, Scores, AgentPresence, AgentDetectionResult, AgentSecurityAnalysisMode, CliOptions, ExitCode, } from "./types/index.js";
|
|
4
4
|
export { EXIT_CODES } from "./types/index.js";
|
|
5
5
|
export { agentRegistry } from "./agents/registry.js";
|
|
6
6
|
export { detectAgents } from "./agents/detect-agents.js";
|
|
@@ -74,6 +74,7 @@ export function renderJsonReport(result) {
|
|
|
74
74
|
summary: result.summary,
|
|
75
75
|
scoringAvailable: result.scoringAvailable,
|
|
76
76
|
scores: result.scores,
|
|
77
|
+
agentSecurityAnalysis: result.agentSecurityAnalysis,
|
|
77
78
|
timing: result.timing,
|
|
78
79
|
diagnostics: {
|
|
79
80
|
warnings: result.diagnostics.warnings.map(sanitizeForOutput),
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { PACKAGE_VERSION } from "../../constants.js";
|
|
2
2
|
import { AGENT_DISPLAY_NAMES } from "../../constants.js";
|
|
3
|
-
import { formatFramework } from "../../detectors/framework.js";
|
|
4
|
-
import { formatLanguage } from "../../detectors/language.js";
|
|
3
|
+
import { formatFramework, formatFrameworks } from "../../detectors/framework.js";
|
|
4
|
+
import { formatLanguage, formatLanguages } from "../../detectors/language.js";
|
|
5
5
|
import { formatMonorepo } from "../../detectors/monorepo.js";
|
|
6
6
|
import { formatPackageManager, formatPackageManagers } from "../../detectors/package-manager.js";
|
|
7
7
|
import { colors, symbolFail, symbolInfo, symbolOk, symbolWarn } from "../../utils/colors.js";
|
|
8
8
|
import { sanitizeTerminalText } from "../../security/redaction.js";
|
|
9
9
|
const DEFAULT_FINDING_LIMIT = 12;
|
|
10
|
+
const LIMITED_ANALYSIS_NOTE = "No supported coding-agent configuration detected; agent-specific security exposure checks are limited.";
|
|
10
11
|
function groupFindings(findings) {
|
|
11
12
|
return {
|
|
12
13
|
critical: findings.filter((f) => f.severity === "critical"),
|
|
@@ -26,6 +27,14 @@ function agentGlyph(configured, detected) {
|
|
|
26
27
|
function formatAgents(agents) {
|
|
27
28
|
return agents.map((id) => AGENT_DISPLAY_NAMES[id] ?? id).join(", ");
|
|
28
29
|
}
|
|
30
|
+
function isMultiStack(result) {
|
|
31
|
+
if (result.repository.monorepo === "multi-project") {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
const frameworks = result.repository.frameworks.filter((id) => id !== "unknown");
|
|
35
|
+
const managers = result.repository.packageManagers.filter((id) => id !== "unknown");
|
|
36
|
+
return frameworks.length > 1 || managers.length > 1;
|
|
37
|
+
}
|
|
29
38
|
function renderFindingBlock(finding, verbose) {
|
|
30
39
|
const lines = [];
|
|
31
40
|
const icon = finding.severity === "critical"
|
|
@@ -62,13 +71,20 @@ export function renderTerminalReport(result, options = {}) {
|
|
|
62
71
|
lines.push(colors.dim("Scanning repository..."));
|
|
63
72
|
lines.push("");
|
|
64
73
|
lines.push(colors.bold("Repository"));
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
result.repository.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
if (isMultiStack(result)) {
|
|
75
|
+
lines.push(` Languages: ${sanitizeTerminalText(formatLanguages(result.repository.languages))}`);
|
|
76
|
+
lines.push(` Frameworks: ${sanitizeTerminalText(formatFrameworks(result.repository.frameworks))}`);
|
|
77
|
+
lines.push(` Package managers: ${sanitizeTerminalText(formatPackageManagers(result.repository.packageManagers))}`);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
lines.push(` Framework: ${sanitizeTerminalText(formatFramework(result.repository.primaryFramework))}`);
|
|
81
|
+
lines.push(` Language: ${sanitizeTerminalText(formatLanguage(result.repository.primaryLanguage))}`);
|
|
82
|
+
const packageManagerLabel = result.repository.packageManagers.length > 1 ||
|
|
83
|
+
result.repository.primaryPackageManager === "unknown"
|
|
84
|
+
? formatPackageManagers(result.repository.packageManagers)
|
|
85
|
+
: formatPackageManager(result.repository.primaryPackageManager);
|
|
86
|
+
lines.push(` Package manager: ${sanitizeTerminalText(packageManagerLabel)}`);
|
|
87
|
+
}
|
|
72
88
|
if (result.repository.monorepo !== "none") {
|
|
73
89
|
lines.push(` Monorepo: ${sanitizeTerminalText(formatMonorepo(result.repository.monorepo))}`);
|
|
74
90
|
}
|
|
@@ -90,10 +106,20 @@ export function renderTerminalReport(result, options = {}) {
|
|
|
90
106
|
lines.push("");
|
|
91
107
|
const grouped = groupFindings(result.findings);
|
|
92
108
|
const total = result.summary.total;
|
|
109
|
+
const limited = result.agentSecurityAnalysis === "limited";
|
|
93
110
|
lines.push(colors.bold("Findings"));
|
|
94
111
|
lines.push("");
|
|
112
|
+
if (limited) {
|
|
113
|
+
lines.push(` ${symbolWarn()} ${sanitizeTerminalText(LIMITED_ANALYSIS_NOTE)}`);
|
|
114
|
+
lines.push("");
|
|
115
|
+
}
|
|
95
116
|
if (total === 0) {
|
|
96
|
-
|
|
117
|
+
if (limited) {
|
|
118
|
+
lines.push(` ${symbolOk()} No agent-configuration findings`);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
lines.push(` ${symbolOk()} No findings`);
|
|
122
|
+
}
|
|
97
123
|
lines.push("");
|
|
98
124
|
}
|
|
99
125
|
else {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -94,6 +94,11 @@ export interface ScanDiagnostics {
|
|
|
94
94
|
warnings: string[];
|
|
95
95
|
errors: string[];
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Whether agent-specific security exposure checks had enough agent configuration
|
|
99
|
+
* to run meaningfully. Additive field — absent consumers may ignore it.
|
|
100
|
+
*/
|
|
101
|
+
export type AgentSecurityAnalysisMode = "full" | "limited";
|
|
97
102
|
export interface ScanResult {
|
|
98
103
|
version: string;
|
|
99
104
|
repository: RepositoryInfo;
|
|
@@ -107,6 +112,11 @@ export interface ScanResult {
|
|
|
107
112
|
scores: Scores | null;
|
|
108
113
|
/** False until the readiness scoring model ships. */
|
|
109
114
|
scoringAvailable: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* `limited` when no supported coding agent is detected/configured.
|
|
117
|
+
* Repository-risk checks may still run; agent exposure claims are not asserted.
|
|
118
|
+
*/
|
|
119
|
+
agentSecurityAnalysis: AgentSecurityAnalysisMode;
|
|
110
120
|
timing: TimingInfo;
|
|
111
121
|
diagnostics: ScanDiagnostics;
|
|
112
122
|
}
|