@dailephd/my-dev-kit-lab 0.2.2 → 0.3.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 +3 -2
- package/dist/scripts/audits/runAudit.js +101 -0
- package/dist/src/audits/codeRot/codeRotDetectors.js +17 -0
- package/dist/src/audits/codeRot/detectors/architectureDriftDetector.js +271 -0
- package/dist/src/audits/codeRot/detectors/crossPlatformRotDetector.js +214 -0
- package/dist/src/audits/codeRot/detectors/deadCodeCandidateDetector.js +344 -0
- package/dist/src/audits/codeRot/detectors/dependencyEnvironmentRotDetector.js +244 -0
- package/dist/src/audits/codeRot/detectors/docsCodeMismatchDetector.js +219 -0
- package/dist/src/audits/codeRot/detectors/duplicateImplementationDetector.js +273 -0
- package/dist/src/audits/codeRot/detectors/packageReleaseRotDetector.js +325 -0
- package/dist/src/audits/codeRot/detectors/securityValidationAssumptionRotDetector.js +385 -0
- package/dist/src/audits/codeRot/detectors/staleCommandReferenceDetector.js +135 -0
- package/dist/src/audits/codeRot/detectors/testRotDetector.js +348 -0
- package/dist/src/audits/codeRot/utils/boundedRead.js +23 -0
- package/dist/src/audits/codeRot/utils/docClaimPatterns.js +62 -0
- package/dist/src/audits/codeRot/utils/docCommandReferences.js +64 -0
- package/dist/src/audits/codeRot/utils/filePatternUtils.js +47 -0
- package/dist/src/audits/codeRot/utils/issueFactories.js +38 -0
- package/dist/src/audits/codeRot/utils/textLines.js +12 -0
- package/dist/src/audits/core/auditConfig.js +125 -0
- package/dist/src/audits/core/auditExitCode.js +75 -0
- package/dist/src/audits/core/auditIssue.js +20 -0
- package/dist/src/audits/core/auditRegistry.js +40 -0
- package/dist/src/audits/core/auditRunner.js +71 -0
- package/dist/src/audits/core/auditTarget.js +19 -0
- package/dist/src/audits/core/auditTypes.js +30 -0
- package/dist/src/audits/core/projectInventory.js +308 -0
- package/dist/src/audits/core/sourceOfTruth.js +339 -0
- package/dist/src/audits/core/textLines.js +24 -0
- package/dist/src/audits/report/auditReportModel.js +241 -0
- package/dist/src/audits/report/auditReportPaths.js +13 -0
- package/dist/src/audits/report/renderAuditJsonReport.js +29 -0
- package/dist/src/audits/report/renderAuditTextReport.js +165 -0
- package/dist/src/audits/report/sanitizeAuditText.js +39 -0
- package/dist/src/audits/report/writeAuditReports.js +27 -0
- package/dist/src/core/resolveCommand.js +53 -2
- package/package.json +3 -2
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { getHighestSeverity, AUDIT_EXIT_CODES } from "../core/auditExitCode.js";
|
|
2
|
+
import { DEFAULT_AUDIT_REGISTRY, selectDetectors } from "../core/auditRegistry.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// v0.3.0 Batch 5 — stable, versioned audit report model.
|
|
5
|
+
//
|
|
6
|
+
// Pure transform: AuditResult (+ the richer AuditTarget the CLI script
|
|
7
|
+
// already has in scope) -> AuditReportModel. No I/O here (that lives in
|
|
8
|
+
// writeAuditReports.ts). This is what replaces the inline
|
|
9
|
+
// buildJsonReport()/renderMinimalTextSummary()/summarizeInventory()/
|
|
10
|
+
// summarizeSourceOfTruth() functions that previously lived directly in
|
|
11
|
+
// scripts/audits/runAudit.ts (Batch 1/2) -- the summarize* condensing logic
|
|
12
|
+
// is preserved verbatim here (inventory/sourceOfTruth shapes are unchanged,
|
|
13
|
+
// see spec 3.2 "inventory"/"sourceOfTruth" sections), just relocated so the
|
|
14
|
+
// CLI script can stay thin.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
export const AUDIT_REPORT_SCHEMA_VERSION = "1.0";
|
|
17
|
+
export function summarizeInventory(inventory) {
|
|
18
|
+
return {
|
|
19
|
+
targetRoot: inventory.targetRoot,
|
|
20
|
+
totalFileCount: inventory.totalFileCount,
|
|
21
|
+
totalScannedFileCount: inventory.totalScannedFileCount,
|
|
22
|
+
skippedFileCount: inventory.skippedFileCount,
|
|
23
|
+
filesByCategory: inventory.filesByCategory,
|
|
24
|
+
filesByExtension: inventory.filesByExtension,
|
|
25
|
+
excludedDirectorySummary: inventory.excludedDirectorySummary,
|
|
26
|
+
warnings: inventory.warnings,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function summarizeSourceOfTruth(sourceOfTruth) {
|
|
30
|
+
const scriptCountByGroup = Object.fromEntries(Object.entries(sourceOfTruth.commands.scriptsByGroup).map(([group, names]) => [group, names.length]));
|
|
31
|
+
const nodeVersionsReferenced = [
|
|
32
|
+
...new Set(sourceOfTruth.ci.workflows.flatMap((w) => w.nodeVersionsReferenced)),
|
|
33
|
+
].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
34
|
+
return {
|
|
35
|
+
packageName: sourceOfTruth.package?.name ?? null,
|
|
36
|
+
packageVersion: sourceOfTruth.package?.version ?? null,
|
|
37
|
+
scriptCountByGroup,
|
|
38
|
+
hasReadme: sourceOfTruth.docs.hasReadme,
|
|
39
|
+
hasChangelog: sourceOfTruth.docs.hasChangelog,
|
|
40
|
+
docsFileCount: sourceOfTruth.docs.files.length,
|
|
41
|
+
ciWorkflowCount: sourceOfTruth.ci.workflows.length,
|
|
42
|
+
nodeVersionsReferenced,
|
|
43
|
+
buildTooling: sourceOfTruth.buildTooling,
|
|
44
|
+
tests: sourceOfTruth.tests,
|
|
45
|
+
security: sourceOfTruth.security,
|
|
46
|
+
experiment: sourceOfTruth.experiment,
|
|
47
|
+
warnings: sourceOfTruth.warnings,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const MAX_JSON_RECOMMENDATIONS = 200;
|
|
51
|
+
const MAX_TEXT_RECOMMENDATIONS = 10;
|
|
52
|
+
const SEVERITY_ORDER = ["blocker", "high", "medium", "low", "info"];
|
|
53
|
+
function severityRankForSort(severity) {
|
|
54
|
+
return SEVERITY_ORDER.indexOf(severity);
|
|
55
|
+
}
|
|
56
|
+
// Aggregates AuditIssue.recommendedAction strings: dedupes identical text,
|
|
57
|
+
// tracks which issues/detectors map to each, sorts blocker/high-severity
|
|
58
|
+
// recommendations first (stable by highest severity among contributing
|
|
59
|
+
// issues, then by first-occurrence order). Only derives text from actual
|
|
60
|
+
// issue.recommendedAction fields -- never invents new recommendation text.
|
|
61
|
+
export function buildRecommendations(issues) {
|
|
62
|
+
const byText = new Map();
|
|
63
|
+
issues.forEach((issue, index) => {
|
|
64
|
+
const text = issue.recommendedAction;
|
|
65
|
+
const existing = byText.get(text);
|
|
66
|
+
if (existing) {
|
|
67
|
+
if (!existing.detectorIds.includes(issue.detectorId))
|
|
68
|
+
existing.detectorIds.push(issue.detectorId);
|
|
69
|
+
existing.issueIds.push(issue.id);
|
|
70
|
+
if (severityRankForSort(issue.severity) < severityRankForSort(existing.highestSeverity)) {
|
|
71
|
+
existing.highestSeverity = issue.severity;
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
byText.set(text, {
|
|
76
|
+
text,
|
|
77
|
+
detectorIds: [issue.detectorId],
|
|
78
|
+
issueIds: [issue.id],
|
|
79
|
+
highestSeverity: issue.severity,
|
|
80
|
+
firstIndex: index,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
const all = [...byText.values()].sort((a, b) => {
|
|
84
|
+
const rankDiff = severityRankForSort(a.highestSeverity) - severityRankForSort(b.highestSeverity);
|
|
85
|
+
if (rankDiff !== 0)
|
|
86
|
+
return rankDiff;
|
|
87
|
+
return a.firstIndex - b.firstIndex;
|
|
88
|
+
});
|
|
89
|
+
return all.slice(0, MAX_JSON_RECOMMENDATIONS).map(({ firstIndex: _firstIndex, ...rec }) => rec);
|
|
90
|
+
}
|
|
91
|
+
export { MAX_TEXT_RECOMMENDATIONS };
|
|
92
|
+
// Precedence (documented per spec 3.7): a fail-on breach is always the
|
|
93
|
+
// primary label when both a breach AND detector errors are present, because
|
|
94
|
+
// the process exit code (what actually blocks automation) is driven by the
|
|
95
|
+
// breach, not by detector errors. "detector errors" is only ever the primary
|
|
96
|
+
// label when there is no breach -- callers that want to know about detector
|
|
97
|
+
// errors independently of the primary label should check
|
|
98
|
+
// summary.detectorErrorCount directly (always present regardless of which
|
|
99
|
+
// verdict label won).
|
|
100
|
+
export function computeVerdictLabel(result, breached) {
|
|
101
|
+
if (breached)
|
|
102
|
+
return "fail-on threshold breached";
|
|
103
|
+
if (result.detectorErrors.length > 0)
|
|
104
|
+
return "detector errors";
|
|
105
|
+
if (result.issues.length === 0)
|
|
106
|
+
return "no issues";
|
|
107
|
+
return "issues found below fail-on threshold";
|
|
108
|
+
}
|
|
109
|
+
export function categorizeDetectorError(message) {
|
|
110
|
+
if (/ENOENT|no such file|ENOTDIR|EACCES|EPERM/i.test(message))
|
|
111
|
+
return "filesystem";
|
|
112
|
+
if (/JSON|unexpected token|parse/i.test(message))
|
|
113
|
+
return "parse";
|
|
114
|
+
return "unknown";
|
|
115
|
+
}
|
|
116
|
+
export function buildAuditReportModel(result, opts) {
|
|
117
|
+
const registry = opts.registry ?? DEFAULT_AUDIT_REGISTRY;
|
|
118
|
+
const generatedAt = opts.generatedAt ?? new Date().toISOString();
|
|
119
|
+
const command = opts.command ?? reconstructCommand(result);
|
|
120
|
+
const issues = result.issues;
|
|
121
|
+
const breached = result.exitCode === AUDIT_EXIT_CODES.THRESHOLD_BREACHED;
|
|
122
|
+
const verdictLabel = computeVerdictLabel(result, breached);
|
|
123
|
+
const issuesByConfidence = countBy(issues, (i) => i.confidence, ["high", "medium", "low"]);
|
|
124
|
+
const issuesByFalsePositiveRisk = countBy(issues, (i) => i.falsePositiveRisk, ["high", "medium", "low"]);
|
|
125
|
+
const issuesByDetector = {};
|
|
126
|
+
for (const issue of issues) {
|
|
127
|
+
issuesByDetector[issue.detectorId] = (issuesByDetector[issue.detectorId] ?? 0) + 1;
|
|
128
|
+
}
|
|
129
|
+
const detectors = buildDetectorReportEntries(registry, result);
|
|
130
|
+
const selectedDetectorCount = detectors.filter((d) => d.status === "selected" || d.status === "error").length;
|
|
131
|
+
const recommendations = buildRecommendations(issues);
|
|
132
|
+
return {
|
|
133
|
+
schemaVersion: AUDIT_REPORT_SCHEMA_VERSION,
|
|
134
|
+
metadata: {
|
|
135
|
+
packageName: result.sourceOfTruth.package?.name ?? null,
|
|
136
|
+
packageVersion: result.sourceOfTruth.package?.version ?? null,
|
|
137
|
+
generatedAt,
|
|
138
|
+
command,
|
|
139
|
+
reportType: "code-rot-audit",
|
|
140
|
+
auditType: result.configSummary.types.join(","),
|
|
141
|
+
auditTypes: result.configSummary.types,
|
|
142
|
+
},
|
|
143
|
+
target: {
|
|
144
|
+
displayName: opts.target.displayName,
|
|
145
|
+
rootPath: opts.target.rootPath,
|
|
146
|
+
targetKind: opts.target.isSelf ? "self" : "external",
|
|
147
|
+
hasPackageJson: opts.target.packageJsonPath !== null,
|
|
148
|
+
hasGitRoot: opts.target.gitRoot !== null,
|
|
149
|
+
},
|
|
150
|
+
config: {
|
|
151
|
+
types: result.configSummary.types,
|
|
152
|
+
include: result.configSummary.include,
|
|
153
|
+
formats: result.configSummary.formats,
|
|
154
|
+
failOn: result.configSummary.failOn,
|
|
155
|
+
out: result.configSummary.out,
|
|
156
|
+
isDefaultRun: result.configSummary.isDefaultRun,
|
|
157
|
+
},
|
|
158
|
+
summary: {
|
|
159
|
+
totalIssues: issues.length,
|
|
160
|
+
issuesBySeverity: result.issueCounts,
|
|
161
|
+
issuesByConfidence,
|
|
162
|
+
issuesByFalsePositiveRisk,
|
|
163
|
+
issuesByDetector,
|
|
164
|
+
releaseBlockingCount: issues.filter((i) => i.releaseBlocking).length,
|
|
165
|
+
implementationBlockingCount: issues.filter((i) => i.implementationBlocking).length,
|
|
166
|
+
autoFixEligibleCount: issues.filter((i) => i.autoFixEligible).length,
|
|
167
|
+
detectorCount: registry.length,
|
|
168
|
+
selectedDetectorCount,
|
|
169
|
+
skippedDetectorCount: result.skippedDetectors.length,
|
|
170
|
+
detectorErrorCount: result.detectorErrors.length,
|
|
171
|
+
noDetectorsRegistered: selectedDetectorCount === 0,
|
|
172
|
+
highestSeverity: getHighestSeverity(issues),
|
|
173
|
+
failOnBreached: breached,
|
|
174
|
+
finalExitCode: result.exitCode,
|
|
175
|
+
finalVerdictLabel: verdictLabel,
|
|
176
|
+
},
|
|
177
|
+
inventory: summarizeInventory(result.inventory),
|
|
178
|
+
sourceOfTruth: summarizeSourceOfTruth(result.sourceOfTruth),
|
|
179
|
+
detectors,
|
|
180
|
+
issues,
|
|
181
|
+
skippedDetectors: result.skippedDetectors,
|
|
182
|
+
detectorErrors: result.detectorErrors.map((e) => ({ ...e, category: categorizeDetectorError(e.message) })),
|
|
183
|
+
recommendations,
|
|
184
|
+
exit: {
|
|
185
|
+
code: result.exitCode,
|
|
186
|
+
reason: result.exitReason,
|
|
187
|
+
failOnThreshold: result.configSummary.failOn,
|
|
188
|
+
breached,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function countBy(issues, selector, levels) {
|
|
193
|
+
const counts = Object.fromEntries(levels.map((l) => [l, 0]));
|
|
194
|
+
for (const issue of issues) {
|
|
195
|
+
counts[selector(issue)] += 1;
|
|
196
|
+
}
|
|
197
|
+
return counts;
|
|
198
|
+
}
|
|
199
|
+
function buildDetectorReportEntries(registry, result) {
|
|
200
|
+
const types = result.configSummary.types;
|
|
201
|
+
const include = result.configSummary.include;
|
|
202
|
+
const selected = new Set(selectDetectors(registry, types, include).map((d) => d.id));
|
|
203
|
+
const skippedIds = new Set(result.skippedDetectors.map((d) => d.id));
|
|
204
|
+
const erroredIds = new Set(result.detectorErrors.map((d) => d.id));
|
|
205
|
+
return registry.map((detector) => {
|
|
206
|
+
let status;
|
|
207
|
+
if (!selected.has(detector.id)) {
|
|
208
|
+
status = "excluded";
|
|
209
|
+
}
|
|
210
|
+
else if (erroredIds.has(detector.id)) {
|
|
211
|
+
status = "error";
|
|
212
|
+
}
|
|
213
|
+
else if (skippedIds.has(detector.id)) {
|
|
214
|
+
status = "skipped";
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
status = "selected";
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
id: detector.id,
|
|
221
|
+
auditType: detector.auditType,
|
|
222
|
+
title: detector.title,
|
|
223
|
+
supportedIncludeAreas: detector.supportedIncludeAreas,
|
|
224
|
+
status,
|
|
225
|
+
issueCount: result.issues.filter((i) => i.detectorId === detector.id).length,
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
function reconstructCommand(result) {
|
|
230
|
+
const parts = ["npm", "run", "audit", "--"];
|
|
231
|
+
const cfg = result.configSummary;
|
|
232
|
+
if (cfg.types.length > 0)
|
|
233
|
+
parts.push("--types", cfg.types.join(","));
|
|
234
|
+
if (cfg.include.length > 0)
|
|
235
|
+
parts.push("--include", cfg.include.join(","));
|
|
236
|
+
if (cfg.formats.length > 0)
|
|
237
|
+
parts.push("--format", cfg.formats.join(","));
|
|
238
|
+
parts.push("--fail-on", cfg.failOn);
|
|
239
|
+
parts.push("--out", cfg.out);
|
|
240
|
+
return parts.join(" ");
|
|
241
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.3.0 Batch 5 — audit report filename constants.
|
|
3
|
+
//
|
|
4
|
+
// Keeps the exact filenames already committed to in Batch 1/2/3/4
|
|
5
|
+
// (tests/audits/auditReportInventoryOutput.test.ts,
|
|
6
|
+
// tests/audits/auditCommandSmoke.test.ts,
|
|
7
|
+
// tests/audits/auditCommandCodeRotSmoke.test.ts all hardcode
|
|
8
|
+
// "code-rot-audit.json"/"code-rot-audit.txt") -- the Batch 5 spec's
|
|
9
|
+
// "suggested" names are only suggestions; the filename convention itself is
|
|
10
|
+
// unchanged, only the JSON content schema changes in this batch.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
export const AUDIT_JSON_REPORT_FILENAME = "code-rot-audit.json";
|
|
13
|
+
export const AUDIT_TEXT_REPORT_FILENAME = "code-rot-audit.txt";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.3.0 Batch 5 — stable JSON report renderer.
|
|
3
|
+
//
|
|
4
|
+
// Pure: JSON.stringify with a fixed, explicit key order (not
|
|
5
|
+
// insertion-order-dependent on how auditReportModel.ts happens to construct
|
|
6
|
+
// the object) and 2-space indent. No sanitization needed here -- JSON.stringify
|
|
7
|
+
// already escapes control characters into safe \u00XX sequences by spec, so
|
|
8
|
+
// hostile evidence content cannot corrupt the JSON structure. See
|
|
9
|
+
// sanitizeAuditText.ts's header comment for the same reasoning from the text
|
|
10
|
+
// renderer's side.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
export function renderAuditJsonReport(model) {
|
|
13
|
+
const ordered = {
|
|
14
|
+
schemaVersion: model.schemaVersion,
|
|
15
|
+
metadata: model.metadata,
|
|
16
|
+
target: model.target,
|
|
17
|
+
config: model.config,
|
|
18
|
+
summary: model.summary,
|
|
19
|
+
inventory: model.inventory,
|
|
20
|
+
sourceOfTruth: model.sourceOfTruth,
|
|
21
|
+
detectors: model.detectors,
|
|
22
|
+
issues: model.issues,
|
|
23
|
+
skippedDetectors: model.skippedDetectors,
|
|
24
|
+
detectorErrors: model.detectorErrors,
|
|
25
|
+
recommendations: model.recommendations,
|
|
26
|
+
exit: model.exit,
|
|
27
|
+
};
|
|
28
|
+
return JSON.stringify(ordered, null, 2);
|
|
29
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { MAX_TEXT_RECOMMENDATIONS } from "./auditReportModel.js";
|
|
2
|
+
import { sanitizeAuditText } from "./sanitizeAuditText.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// v0.3.0 Batch 5 — human-readable text report renderer.
|
|
5
|
+
//
|
|
6
|
+
// Pure: takes an already-built AuditReportModel and renders terminal-
|
|
7
|
+
// friendly text. Sanitizes any text sourced from scanned project content
|
|
8
|
+
// (issue titles/descriptions/evidence excerpts, recommendedAction strings)
|
|
9
|
+
// via sanitizeAuditText() so a hostile source file cannot poison the report
|
|
10
|
+
// with ANSI escapes or a fake section header. Evidence excerpts are quoted
|
|
11
|
+
// with a leading "> " marker precisely so a fake "===" divider or a fake
|
|
12
|
+
// "[BLOCKER]"-looking line embedded in scanned content reads as quoted
|
|
13
|
+
// evidence, not as a real report section (see spec 3.9/tests/audits/
|
|
14
|
+
// auditReportSanitization.test.ts).
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
const SEVERITY_HEADING_ORDER = ["blocker", "high", "medium", "low", "info"];
|
|
17
|
+
const MAX_EVIDENCE_PER_ISSUE = 2;
|
|
18
|
+
const MAX_EXCERPT_LENGTH = 300;
|
|
19
|
+
function divider(char = "=", width = 72) {
|
|
20
|
+
return char.repeat(width);
|
|
21
|
+
}
|
|
22
|
+
function sanitizeLine(raw) {
|
|
23
|
+
// Defensive bound even for non-excerpt text (titles/descriptions) --
|
|
24
|
+
// sanitizeAuditText() strips control chars and only truncates past
|
|
25
|
+
// maxLength, so ordinary short strings pass through unchanged. Also
|
|
26
|
+
// collapses any embedded real newlines to a single space: these fields
|
|
27
|
+
// (title/description/category/reason/message) are rendered as a single
|
|
28
|
+
// "label: value" line, so a hostile source containing a literal newline
|
|
29
|
+
// must not be allowed to inject what looks like a second, unindented
|
|
30
|
+
// report line.
|
|
31
|
+
return sanitizeAuditText(raw, 500).replace(/\r\n|\r|\n/g, " ");
|
|
32
|
+
}
|
|
33
|
+
// Renders a (possibly multi-line) evidence excerpt as one or more clearly
|
|
34
|
+
// quoted lines, each individually prefixed with "> " -- this is what stops a
|
|
35
|
+
// hostile excerpt containing an embedded newline plus a fake "===" divider
|
|
36
|
+
// or a fake "[BLOCKER] ..." header line from ever appearing as a bare,
|
|
37
|
+
// unindented line that could be mistaken for real report structure. Every
|
|
38
|
+
// physical line of the excerpt is quoted, not just the first.
|
|
39
|
+
function renderQuotedExcerpt(raw, indent) {
|
|
40
|
+
const sanitized = sanitizeAuditText(raw, MAX_EXCERPT_LENGTH);
|
|
41
|
+
return sanitized.split(/\r\n|\r|\n/).map((line) => `${indent}> ${line}`);
|
|
42
|
+
}
|
|
43
|
+
export function renderAuditTextReport(model) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
lines.push("my-dev-kit-lab audit report");
|
|
46
|
+
lines.push(divider());
|
|
47
|
+
lines.push(`Schema version: ${model.schemaVersion}`);
|
|
48
|
+
lines.push(`Generated: ${model.metadata.generatedAt}`);
|
|
49
|
+
lines.push(`Report type: ${model.metadata.reportType} (audit type: ${model.metadata.auditType})`);
|
|
50
|
+
lines.push(`Package: ${model.metadata.packageName ?? "(none)"}@${model.metadata.packageVersion ?? "?"}`);
|
|
51
|
+
lines.push("");
|
|
52
|
+
lines.push("Target");
|
|
53
|
+
lines.push(divider("-"));
|
|
54
|
+
lines.push(` ${sanitizeLine(model.target.displayName)} (${model.target.rootPath})`);
|
|
55
|
+
lines.push(` kind=${model.target.targetKind} hasPackageJson=${model.target.hasPackageJson} hasGitRoot=${model.target.hasGitRoot}`);
|
|
56
|
+
lines.push("");
|
|
57
|
+
lines.push("Config");
|
|
58
|
+
lines.push(divider("-"));
|
|
59
|
+
lines.push(` types=${model.config.types.join(",")} include=${model.config.include.join(",")}`);
|
|
60
|
+
lines.push(` formats=${model.config.formats.join(",")} failOn=${model.config.failOn} isDefaultRun=${model.config.isDefaultRun}`);
|
|
61
|
+
lines.push(` out=${model.config.out}`);
|
|
62
|
+
lines.push("");
|
|
63
|
+
lines.push("Detectors");
|
|
64
|
+
lines.push(divider("-"));
|
|
65
|
+
for (const d of model.detectors) {
|
|
66
|
+
lines.push(` [${d.status.padEnd(8)}] ${d.id} (${d.auditType}) issues=${d.issueCount}`);
|
|
67
|
+
}
|
|
68
|
+
lines.push("");
|
|
69
|
+
lines.push("Inventory summary");
|
|
70
|
+
lines.push(divider("-"));
|
|
71
|
+
lines.push(` total=${model.inventory.totalFileCount} scanned=${model.inventory.totalScannedFileCount} skipped=${model.inventory.skippedFileCount}`);
|
|
72
|
+
lines.push(` source=${model.inventory.filesByCategory.source} tests=${model.inventory.filesByCategory.tests} docs=${model.inventory.filesByCategory.docs} package=${model.inventory.filesByCategory.package} config=${model.inventory.filesByCategory.config} scripts=${model.inventory.filesByCategory.scripts} ci=${model.inventory.filesByCategory.ci} generated=${model.inventory.filesByCategory.generated} report=${model.inventory.filesByCategory.report} unknown=${model.inventory.filesByCategory.unknown}`);
|
|
73
|
+
if (model.inventory.warnings.length > 0) {
|
|
74
|
+
lines.push(` warnings: ${model.inventory.warnings.length}`);
|
|
75
|
+
}
|
|
76
|
+
lines.push("");
|
|
77
|
+
lines.push("Source-of-truth summary");
|
|
78
|
+
lines.push(divider("-"));
|
|
79
|
+
lines.push(` package: ${model.sourceOfTruth.packageName ?? "(none)"}@${model.sourceOfTruth.packageVersion ?? "?"}`);
|
|
80
|
+
lines.push(` README present=${model.sourceOfTruth.hasReadme} CHANGELOG present=${model.sourceOfTruth.hasChangelog} docs files=${model.sourceOfTruth.docsFileCount}`);
|
|
81
|
+
lines.push(` CI workflows=${model.sourceOfTruth.ciWorkflowCount} node versions referenced=${model.sourceOfTruth.nodeVersionsReferenced.join(",") || "(none)"}`);
|
|
82
|
+
if (model.sourceOfTruth.warnings.length > 0) {
|
|
83
|
+
lines.push(` warnings: ${model.sourceOfTruth.warnings.length}`);
|
|
84
|
+
}
|
|
85
|
+
lines.push("");
|
|
86
|
+
lines.push("Issue summary");
|
|
87
|
+
lines.push(divider("-"));
|
|
88
|
+
lines.push(` Total issues: ${model.summary.totalIssues}`);
|
|
89
|
+
lines.push(` By severity: blocker=${model.summary.issuesBySeverity.blocker} high=${model.summary.issuesBySeverity.high} medium=${model.summary.issuesBySeverity.medium} low=${model.summary.issuesBySeverity.low} info=${model.summary.issuesBySeverity.info}`);
|
|
90
|
+
lines.push(` By confidence: high=${model.summary.issuesByConfidence.high} medium=${model.summary.issuesByConfidence.medium} low=${model.summary.issuesByConfidence.low}`);
|
|
91
|
+
lines.push(` By false-positive risk: high=${model.summary.issuesByFalsePositiveRisk.high} medium=${model.summary.issuesByFalsePositiveRisk.medium} low=${model.summary.issuesByFalsePositiveRisk.low}`);
|
|
92
|
+
lines.push(` Release-blocking=${model.summary.releaseBlockingCount} Implementation-blocking=${model.summary.implementationBlockingCount} Auto-fix-eligible=${model.summary.autoFixEligibleCount}`);
|
|
93
|
+
lines.push(` Detectors: total=${model.summary.detectorCount} selected=${model.summary.selectedDetectorCount} skipped=${model.summary.skippedDetectorCount} errors=${model.summary.detectorErrorCount}`);
|
|
94
|
+
lines.push(` Highest severity: ${model.summary.highestSeverity ?? "(none)"}`);
|
|
95
|
+
lines.push(` Verdict: ${model.summary.finalVerdictLabel}`);
|
|
96
|
+
lines.push("");
|
|
97
|
+
if (model.recommendations.length > 0) {
|
|
98
|
+
lines.push(`Top recommendations (showing up to ${MAX_TEXT_RECOMMENDATIONS} of ${model.recommendations.length})`);
|
|
99
|
+
lines.push(divider("-"));
|
|
100
|
+
for (const rec of model.recommendations.slice(0, MAX_TEXT_RECOMMENDATIONS)) {
|
|
101
|
+
lines.push(` - [${rec.highestSeverity}] ${sanitizeLine(rec.text)} (${rec.issueIds.length} issue(s), detector(s): ${rec.detectorIds.join(", ")})`);
|
|
102
|
+
}
|
|
103
|
+
lines.push("");
|
|
104
|
+
}
|
|
105
|
+
lines.push("Issues (grouped by severity)");
|
|
106
|
+
lines.push(divider("-"));
|
|
107
|
+
if (model.issues.length === 0) {
|
|
108
|
+
lines.push(" (no issues in this run)");
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
for (const severity of SEVERITY_HEADING_ORDER) {
|
|
112
|
+
const group = model.issues.filter((i) => i.severity === severity);
|
|
113
|
+
if (group.length === 0)
|
|
114
|
+
continue;
|
|
115
|
+
lines.push(` -- ${severity.toUpperCase()} (${group.length}) --`);
|
|
116
|
+
for (const issue of group) {
|
|
117
|
+
lines.push(...renderIssueBlock(issue));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
lines.push("");
|
|
122
|
+
lines.push("Skipped detectors");
|
|
123
|
+
lines.push(divider("-"));
|
|
124
|
+
if (model.skippedDetectors.length === 0) {
|
|
125
|
+
lines.push(" (none)");
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
for (const s of model.skippedDetectors) {
|
|
129
|
+
lines.push(` [${s.id}] ${sanitizeLine(s.reason)}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
lines.push("");
|
|
133
|
+
lines.push("Detector errors");
|
|
134
|
+
lines.push(divider("-"));
|
|
135
|
+
if (model.detectorErrors.length === 0) {
|
|
136
|
+
lines.push(" (none)");
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
for (const e of model.detectorErrors) {
|
|
140
|
+
lines.push(` [${e.id}] (${e.category}) ${sanitizeLine(e.message)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
lines.push("");
|
|
144
|
+
lines.push("Exit result");
|
|
145
|
+
lines.push(divider("-"));
|
|
146
|
+
lines.push(` code=${model.exit.code} failOnThreshold=${model.exit.failOnThreshold} breached=${model.exit.breached}`);
|
|
147
|
+
lines.push(` reason: ${sanitizeLine(model.exit.reason)}`);
|
|
148
|
+
return lines.join("\n");
|
|
149
|
+
}
|
|
150
|
+
function renderIssueBlock(issue) {
|
|
151
|
+
const lines = [];
|
|
152
|
+
lines.push(` * ${sanitizeLine(issue.title)}`);
|
|
153
|
+
lines.push(` severity=${issue.severity} confidence=${issue.confidence} category=${sanitizeLine(issue.category)}`);
|
|
154
|
+
lines.push(` detector=${issue.detectorId} id=${issue.id}`);
|
|
155
|
+
for (const evidence of issue.evidence.slice(0, MAX_EVIDENCE_PER_ISSUE)) {
|
|
156
|
+
if (evidence.excerpt) {
|
|
157
|
+
lines.push(...renderQuotedExcerpt(evidence.excerpt, " "));
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
lines.push(` > ${sanitizeLine(evidence.message)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
lines.push(` recommendedAction: ${sanitizeLine(issue.recommendedAction)}`);
|
|
164
|
+
return lines;
|
|
165
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.3.0 Batch 5 — audit text-report sanitization.
|
|
3
|
+
//
|
|
4
|
+
// Same technique as src/securityValidation/attackScenarios/exploitEvidence.ts's
|
|
5
|
+
// stripUnsafeControlChars()/sanitizeForTextReport() (strip ANSI CSI escapes
|
|
6
|
+
// and other unsafe control bytes before printing hostile-sourced text into a
|
|
7
|
+
// terminal-readable report). Deliberately NOT imported from
|
|
8
|
+
// src/securityValidation/ -- audit and security-validation are kept as
|
|
9
|
+
// independent frameworks throughout this codebase (see the header comment in
|
|
10
|
+
// src/audits/core/auditIssue.ts), and this is a small, self-contained,
|
|
11
|
+
// dependency-free function, so a 10-line local copy is cheaper than adding a
|
|
12
|
+
// cross-framework import for one helper.
|
|
13
|
+
//
|
|
14
|
+
// JSON output does not need this: JSON.stringify already escapes control
|
|
15
|
+
// characters (including ESC, 0x1b) into safe \u00XX sequences by
|
|
16
|
+
// specification, so hostile control bytes can never corrupt JSON structure.
|
|
17
|
+
// This sanitizer is used only by renderAuditTextReport.ts.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// ANSI CSI (Control Sequence Introducer) escape sequences, e.g. "\x1b[31m".
|
|
20
|
+
const ANSI_CSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
21
|
+
// Other C0 control characters except common whitespace (tab \t, newline \n,
|
|
22
|
+
// carriage return \r), plus the DEL character (0x7f).
|
|
23
|
+
// eslint-disable-next-line no-control-regex
|
|
24
|
+
const OTHER_CONTROL_CHARS_PATTERN = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
|
|
25
|
+
export function stripUnsafeControlChars(raw) {
|
|
26
|
+
return raw.replace(ANSI_CSI_PATTERN, "").replace(OTHER_CONTROL_CHARS_PATTERN, "");
|
|
27
|
+
}
|
|
28
|
+
const DEFAULT_MAX_TEXT_EXCERPT_LENGTH = 300;
|
|
29
|
+
// Bounds+sanitizes a string for inclusion in the text report: strips unsafe
|
|
30
|
+
// control bytes first (so a payload cannot hide behind control characters),
|
|
31
|
+
// then re-bounds length defensively even though callers (e.g. AuditEvidence
|
|
32
|
+
// excerpts) are usually already bounded upstream by boundExcerpt() in
|
|
33
|
+
// auditIssue.ts.
|
|
34
|
+
export function sanitizeAuditText(raw, maxLength = DEFAULT_MAX_TEXT_EXCERPT_LENGTH) {
|
|
35
|
+
const stripped = stripUnsafeControlChars(raw);
|
|
36
|
+
if (stripped.length <= maxLength)
|
|
37
|
+
return stripped;
|
|
38
|
+
return `${stripped.slice(0, maxLength)}... [truncated, ${stripped.length} chars total]`;
|
|
39
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AUDIT_JSON_REPORT_FILENAME, AUDIT_TEXT_REPORT_FILENAME } from "./auditReportPaths.js";
|
|
4
|
+
import { renderAuditJsonReport } from "./renderAuditJsonReport.js";
|
|
5
|
+
import { renderAuditTextReport } from "./renderAuditTextReport.js";
|
|
6
|
+
export function writeAuditReports(options) {
|
|
7
|
+
const { model, config, outDir } = options;
|
|
8
|
+
const resolvedOutDir = path.resolve(outDir);
|
|
9
|
+
const writtenPaths = [];
|
|
10
|
+
if (config.formats.length === 0) {
|
|
11
|
+
return { writtenPaths };
|
|
12
|
+
}
|
|
13
|
+
if (!fs.existsSync(resolvedOutDir)) {
|
|
14
|
+
fs.mkdirSync(resolvedOutDir, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
if (config.formats.includes("json")) {
|
|
17
|
+
const jsonPath = path.join(resolvedOutDir, AUDIT_JSON_REPORT_FILENAME);
|
|
18
|
+
fs.writeFileSync(jsonPath, renderAuditJsonReport(model), "utf8");
|
|
19
|
+
writtenPaths.push(jsonPath);
|
|
20
|
+
}
|
|
21
|
+
if (config.formats.includes("text")) {
|
|
22
|
+
const textPath = path.join(resolvedOutDir, AUDIT_TEXT_REPORT_FILENAME);
|
|
23
|
+
fs.writeFileSync(textPath, renderAuditTextReport(model), "utf8");
|
|
24
|
+
writtenPaths.push(textPath);
|
|
25
|
+
}
|
|
26
|
+
return { writtenPaths };
|
|
27
|
+
}
|
|
@@ -4,17 +4,27 @@ const windowsExtensionPreference = [".cmd", ".exe", ".bat", ".ps1", ""];
|
|
|
4
4
|
export function resolveCommand(command, options = {}) {
|
|
5
5
|
const platform = options.platform ?? process.platform;
|
|
6
6
|
const env = options.env ?? process.env;
|
|
7
|
+
const cwd = options.cwd ?? process.cwd();
|
|
7
8
|
if (platform !== "win32") {
|
|
9
|
+
const posixCandidate = findPosixCommandCandidate(command, env, cwd);
|
|
10
|
+
if (!posixCandidate) {
|
|
11
|
+
return {
|
|
12
|
+
originalCommand: command,
|
|
13
|
+
command,
|
|
14
|
+
argsPrefix: [],
|
|
15
|
+
resolutionKind: "unavailable",
|
|
16
|
+
warnings: [`Command was not found on PATH: ${command}`]
|
|
17
|
+
};
|
|
18
|
+
}
|
|
8
19
|
return {
|
|
9
20
|
originalCommand: command,
|
|
10
21
|
command,
|
|
11
22
|
argsPrefix: [],
|
|
12
23
|
resolutionKind: "direct",
|
|
13
|
-
resolvedPath:
|
|
24
|
+
resolvedPath: posixCandidate,
|
|
14
25
|
warnings: []
|
|
15
26
|
};
|
|
16
27
|
}
|
|
17
|
-
const cwd = options.cwd ?? process.cwd();
|
|
18
28
|
const candidate = findWindowsCommandCandidate(command, env, cwd);
|
|
19
29
|
if (!candidate) {
|
|
20
30
|
return {
|
|
@@ -68,6 +78,47 @@ function commandForWindowsCandidate(originalCommand, resolvedPath, env, allowPow
|
|
|
68
78
|
warnings: []
|
|
69
79
|
};
|
|
70
80
|
}
|
|
81
|
+
// POSIX (Linux/macOS) command resolution. Unlike the Windows branch above,
|
|
82
|
+
// there is no extension-guessing -- a bare command name must match exactly
|
|
83
|
+
// one PATH entry, and the match must actually be executable (checked via
|
|
84
|
+
// X_OK, not just file existence, so a non-executable regular file with a
|
|
85
|
+
// matching name is correctly treated as absent). This mirrors the Windows
|
|
86
|
+
// branch's "search PATH, report unavailable if nothing matches" contract:
|
|
87
|
+
// previously this platform branch unconditionally reported "direct" without
|
|
88
|
+
// ever checking PATH, so a genuinely-missing command (e.g. codeql, semgrep)
|
|
89
|
+
// was only discovered as absent when the downstream spawn failed with
|
|
90
|
+
// ENOENT -- which staticScans/codeql.ts and staticScans/semgrep.ts
|
|
91
|
+
// misclassify as a MAJOR/failed finding rather than a graceful skip,
|
|
92
|
+
// causing security:validate to falsely report a release blocker on
|
|
93
|
+
// Linux/macOS whenever an optional static-analysis tool is not installed.
|
|
94
|
+
function findPosixCommandCandidate(command, env, cwd) {
|
|
95
|
+
if (hasPathSeparator(command) || path.isAbsolute(command)) {
|
|
96
|
+
const resolved = path.resolve(cwd, command);
|
|
97
|
+
return isExecutablePosixFile(resolved) ? resolved : undefined;
|
|
98
|
+
}
|
|
99
|
+
for (const searchDir of getPosixPathEntries(env)) {
|
|
100
|
+
const candidate = path.join(searchDir, command);
|
|
101
|
+
if (isExecutablePosixFile(candidate)) {
|
|
102
|
+
return candidate;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
function getPosixPathEntries(env) {
|
|
108
|
+
const pathValue = env.PATH ?? env.Path ?? "";
|
|
109
|
+
return pathValue.split(path.delimiter).filter(Boolean);
|
|
110
|
+
}
|
|
111
|
+
function isExecutablePosixFile(candidatePath) {
|
|
112
|
+
try {
|
|
113
|
+
if (!fs.statSync(candidatePath).isFile())
|
|
114
|
+
return false;
|
|
115
|
+
fs.accessSync(candidatePath, fs.constants.X_OK);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
71
122
|
function findWindowsCommandCandidate(command, env, cwd) {
|
|
72
123
|
if (hasPathSeparator(command) || path.isAbsolute(command)) {
|
|
73
124
|
return findCandidateInDirectory(path.resolve(cwd, command));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dailephd/my-dev-kit-lab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Evidence, benchmark, and evaluation companion for my-dev-kit.",
|
|
6
6
|
"bin": {
|
|
@@ -77,7 +77,8 @@
|
|
|
77
77
|
"security:semgrep": "tsx scripts/security/runSemgrep.ts",
|
|
78
78
|
"test:security": "vitest run tests/security/",
|
|
79
79
|
"test:fuzz:smoke": "tsx scripts/security/runFuzzSmoke.ts",
|
|
80
|
-
"security:validate": "tsx scripts/security/validate.ts"
|
|
80
|
+
"security:validate": "tsx scripts/security/validate.ts",
|
|
81
|
+
"audit": "tsx scripts/audits/runAudit.ts"
|
|
81
82
|
},
|
|
82
83
|
"devDependencies": {
|
|
83
84
|
"@types/node": "^24.0.0",
|