solidity-argus 0.3.3 → 0.3.5
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/package.json +1 -1
- package/src/agents/argus-prompt.ts +67 -8
- package/src/agents/scribe-prompt.ts +13 -5
- package/src/cli/commands/init.ts +1 -1
- package/src/cli/index.ts +0 -0
- package/src/config/schema.ts +7 -2
- package/src/create-hooks.ts +116 -27
- package/src/features/audit-enforcer/audit-enforcer.ts +31 -2
- package/src/features/migration/index.ts +14 -0
- package/src/features/migration/migration-adapter.ts +151 -0
- package/src/features/migration/parity-telemetry.ts +133 -0
- package/src/features/persistent-state/audit-state-manager.ts +28 -6
- package/src/features/persistent-state/event-sink.ts +175 -0
- package/src/features/persistent-state/findings-materializer.ts +51 -0
- package/src/features/persistent-state/index.ts +2 -0
- package/src/features/persistent-state/run-finalizer.ts +192 -0
- package/src/features/persistent-state/run-journal.ts +15 -4
- package/src/hooks/agent-tracker.ts +15 -0
- package/src/hooks/event-hook.ts +93 -1
- package/src/hooks/system-prompt-hook.ts +20 -0
- package/src/hooks/tool-tracking-hook.ts +263 -33
- package/src/shared/audit-artifact-resolver.ts +75 -0
- package/src/shared/drop-diagnostics.ts +108 -0
- package/src/shared/file-utils.ts +7 -2
- package/src/shared/index.ts +14 -0
- package/src/shared/path-root-resolver.ts +34 -0
- package/src/shared/report-path-resolver.ts +70 -0
- package/src/solodit-lifecycle.ts +86 -7
- package/src/state/adapters.ts +262 -0
- package/src/state/index.ts +15 -0
- package/src/state/projectors.ts +437 -0
- package/src/state/schemas.ts +453 -0
- package/src/state/types.ts +6 -0
- package/src/tools/report-generator-tool.ts +647 -36
- package/src/tools/report-preflight.ts +79 -0
- package/src/tools/solodit-search-tool.ts +15 -24
- package/src/utils/solodit-health.ts +18 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createLogger } from "./logger"
|
|
2
|
+
|
|
3
|
+
const logger = createLogger()
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* "warn": log and continue (default). "error": collect, continue, surface.
|
|
7
|
+
* "strict-fail": collect, then throw after all diagnostics gathered.
|
|
8
|
+
*/
|
|
9
|
+
export type DropPolicy = "warn" | "error" | "strict-fail"
|
|
10
|
+
|
|
11
|
+
export type DropReason = {
|
|
12
|
+
code: string
|
|
13
|
+
field?: string
|
|
14
|
+
message: string
|
|
15
|
+
policy: DropPolicy
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type DropDiagnostic = {
|
|
19
|
+
type: "drop"
|
|
20
|
+
source: string
|
|
21
|
+
tool?: string
|
|
22
|
+
reason: DropReason
|
|
23
|
+
timestamp: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type DropDiagnosticsCollector = {
|
|
27
|
+
warn(code: string, message: string, field?: string): void
|
|
28
|
+
error(code: string, message: string, field?: string): void
|
|
29
|
+
getDiagnostics(): DropDiagnostic[]
|
|
30
|
+
hasErrors(): boolean
|
|
31
|
+
throwIfStrict(): void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Thrown in strict-fail mode when error-level diagnostics exist. */
|
|
35
|
+
export class DropDiagnosticsError extends Error {
|
|
36
|
+
public readonly diagnostics: DropDiagnostic[]
|
|
37
|
+
|
|
38
|
+
constructor(diagnostics: DropDiagnostic[]) {
|
|
39
|
+
const errorDiags = diagnostics.filter(
|
|
40
|
+
(d) => d.reason.policy === "strict-fail" || d.reason.policy === "error",
|
|
41
|
+
)
|
|
42
|
+
const summary = errorDiags.map((d) => `[${d.reason.code}] ${d.reason.message}`).join("; ")
|
|
43
|
+
super(`Drop diagnostics: ${errorDiags.length} error(s) — ${summary}`)
|
|
44
|
+
this.name = "DropDiagnosticsError"
|
|
45
|
+
this.diagnostics = diagnostics
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createDropDiagnosticsCollector(
|
|
50
|
+
policy: DropPolicy,
|
|
51
|
+
source: string,
|
|
52
|
+
tool?: string,
|
|
53
|
+
): DropDiagnosticsCollector {
|
|
54
|
+
const diagnostics: DropDiagnostic[] = []
|
|
55
|
+
let errorCount = 0
|
|
56
|
+
|
|
57
|
+
function push(code: string, message: string, level: "warn" | "error", field?: string): void {
|
|
58
|
+
const effectivePolicy: DropPolicy = level === "error" ? policy : "warn"
|
|
59
|
+
const diagnostic: DropDiagnostic = {
|
|
60
|
+
type: "drop",
|
|
61
|
+
source,
|
|
62
|
+
tool,
|
|
63
|
+
reason: {
|
|
64
|
+
code,
|
|
65
|
+
message,
|
|
66
|
+
policy: effectivePolicy,
|
|
67
|
+
...(field !== undefined ? { field } : {}),
|
|
68
|
+
},
|
|
69
|
+
timestamp: Date.now(),
|
|
70
|
+
}
|
|
71
|
+
diagnostics.push(diagnostic)
|
|
72
|
+
|
|
73
|
+
if (level === "error") {
|
|
74
|
+
errorCount++
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const logMsg = `[${source}${tool ? `:${tool}` : ""}] ${code}${field ? ` (field: ${field})` : ""}: ${message}`
|
|
78
|
+
if (level === "error") {
|
|
79
|
+
logger.warn(logMsg)
|
|
80
|
+
} else {
|
|
81
|
+
logger.info(logMsg)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
warn(code: string, message: string, field?: string): void {
|
|
87
|
+
push(code, message, "warn", field)
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
error(code: string, message: string, field?: string): void {
|
|
91
|
+
push(code, message, "error", field)
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
getDiagnostics(): DropDiagnostic[] {
|
|
95
|
+
return [...diagnostics]
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
hasErrors(): boolean {
|
|
99
|
+
return errorCount > 0
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
throwIfStrict(): void {
|
|
103
|
+
if (policy === "strict-fail" && errorCount > 0) {
|
|
104
|
+
throw new DropDiagnosticsError(diagnostics)
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/shared/file-utils.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs"
|
|
2
2
|
import { join } from "node:path"
|
|
3
3
|
import { stripJsoncComments } from "./jsonc-parser"
|
|
4
|
+
import { defaultRootResolver } from "./path-root-resolver"
|
|
4
5
|
|
|
5
6
|
export type ConfigFormat = "json" | "jsonc" | "none"
|
|
6
7
|
|
|
@@ -10,9 +11,13 @@ export interface ConfigFileInfo {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export function detectConfigFile(basePath: string): ConfigFileInfo {
|
|
14
|
+
const rootCandidates = defaultRootResolver.readRoots(basePath).flatMap((rootPath) => [
|
|
15
|
+
{ path: join(rootPath, "solidity-argus.jsonc"), format: "jsonc" as const },
|
|
16
|
+
{ path: join(rootPath, "solidity-argus.json"), format: "json" as const },
|
|
17
|
+
])
|
|
18
|
+
|
|
13
19
|
const candidates = [
|
|
14
|
-
|
|
15
|
-
{ path: join(basePath, ".opencode", "solidity-argus.json"), format: "json" as const },
|
|
20
|
+
...rootCandidates,
|
|
16
21
|
{ path: join(basePath, "solidity-argus.jsonc"), format: "jsonc" as const },
|
|
17
22
|
{ path: join(basePath, "solidity-argus.json"), format: "json" as const },
|
|
18
23
|
]
|
package/src/shared/index.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
export {
|
|
2
|
+
ArtifactResolverError,
|
|
3
|
+
type AuditArtifactPaths,
|
|
4
|
+
type AuditArtifactResolver,
|
|
5
|
+
createAuditArtifactResolver,
|
|
6
|
+
} from "./audit-artifact-resolver"
|
|
1
7
|
export { extractContractNames, hasBinary, parseSolcVersion } from "./binary-utils"
|
|
2
8
|
export { deepMerge } from "./deep-merge"
|
|
3
9
|
export {
|
|
@@ -9,3 +15,11 @@ export {
|
|
|
9
15
|
export { stripJsoncComments } from "./jsonc-parser"
|
|
10
16
|
export { createLogger, type Logger, type LoggerConfig } from "./logger"
|
|
11
17
|
export { findFoundryProjectDir, resolveProjectDir } from "./project-utils"
|
|
18
|
+
export {
|
|
19
|
+
formatReportDate,
|
|
20
|
+
ReportPathError,
|
|
21
|
+
type ReportPathOptions,
|
|
22
|
+
type ResolvedReportPath,
|
|
23
|
+
resolveReportPath,
|
|
24
|
+
sanitizeContractName,
|
|
25
|
+
} from "./report-path-resolver"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
export interface ArgusRootResolver {
|
|
5
|
+
writeRoot(projectDir: string): string
|
|
6
|
+
readRoots(projectDir: string): string[]
|
|
7
|
+
resolveReadPath(projectDir: string, relativePath: string): string | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
class DefaultArgusRootResolver implements ArgusRootResolver {
|
|
11
|
+
writeRoot(projectDir: string): string {
|
|
12
|
+
return join(projectDir, ".argus")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
readRoots(projectDir: string): string[] {
|
|
16
|
+
return [this.writeRoot(projectDir), join(projectDir, ".opencode")]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
resolveReadPath(projectDir: string, relativePath: string): string | null {
|
|
20
|
+
for (const root of this.readRoots(projectDir)) {
|
|
21
|
+
const candidatePath = join(root, relativePath)
|
|
22
|
+
if (existsSync(candidatePath)) {
|
|
23
|
+
return candidatePath
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createArgusRootResolver(): ArgusRootResolver {
|
|
31
|
+
return new DefaultArgusRootResolver()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const defaultRootResolver: ArgusRootResolver = createArgusRootResolver()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
|
|
3
|
+
export class ReportPathError extends Error {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(message)
|
|
6
|
+
this.name = "ReportPathError"
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ReportPathOptions {
|
|
11
|
+
/** Contract name, e.g. "VulnerableVault" */
|
|
12
|
+
contractName: string
|
|
13
|
+
/** If not provided, use new Date() */
|
|
14
|
+
date?: Date
|
|
15
|
+
/** Canonical output directory (from config or default) */
|
|
16
|
+
outputDir: string
|
|
17
|
+
/** Optional run_id for run-scoped naming */
|
|
18
|
+
runId?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ResolvedReportPath {
|
|
22
|
+
/** "VulnerableVault-security-audit-2026-02-21.md" */
|
|
23
|
+
filename: string
|
|
24
|
+
/** Full absolute path */
|
|
25
|
+
filePath: string
|
|
26
|
+
/** The directory used */
|
|
27
|
+
outputDir: string
|
|
28
|
+
/** runId if provided, else filename (deterministic identity) */
|
|
29
|
+
canonicalId: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatReportDate(date: Date): string {
|
|
33
|
+
const year = date.getUTCFullYear()
|
|
34
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0")
|
|
35
|
+
const day = String(date.getUTCDate()).padStart(2, "0")
|
|
36
|
+
return `${year}-${month}-${day}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function sanitizeContractName(name: string): string {
|
|
40
|
+
return name
|
|
41
|
+
.replace(/\s+/g, "-")
|
|
42
|
+
.replace(/[^a-zA-Z0-9-]/g, "")
|
|
43
|
+
.replace(/-+/g, "-")
|
|
44
|
+
.replace(/^-|-$/g, "")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolveReportPath(options: ReportPathOptions): ResolvedReportPath {
|
|
48
|
+
const { contractName, date, outputDir, runId } = options
|
|
49
|
+
|
|
50
|
+
if (!contractName || contractName.trim() === "") {
|
|
51
|
+
throw new ReportPathError("contractName must not be empty")
|
|
52
|
+
}
|
|
53
|
+
if (!outputDir || outputDir.trim() === "") {
|
|
54
|
+
throw new ReportPathError("outputDir must not be empty")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const resolvedDate = date ?? new Date()
|
|
58
|
+
const dateStr = formatReportDate(resolvedDate)
|
|
59
|
+
const sanitizedName = sanitizeContractName(contractName)
|
|
60
|
+
const filename = `${sanitizedName}-security-audit-${dateStr}.md`
|
|
61
|
+
const filePath = join(outputDir, filename)
|
|
62
|
+
const canonicalId = runId ?? filename
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
filename,
|
|
66
|
+
filePath,
|
|
67
|
+
outputDir,
|
|
68
|
+
canonicalId,
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/solodit-lifecycle.ts
CHANGED
|
@@ -6,6 +6,15 @@ interface SoloditChildProcess {
|
|
|
6
6
|
kill(signal?: number): void
|
|
7
7
|
unref(): void
|
|
8
8
|
readonly exited: Promise<number | null>
|
|
9
|
+
readonly pid?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type LifecycleState = "starting" | "running" | "failed" | "stopped"
|
|
13
|
+
|
|
14
|
+
export interface LifecycleStatus {
|
|
15
|
+
state: LifecycleState
|
|
16
|
+
error?: string
|
|
17
|
+
pid?: number
|
|
9
18
|
}
|
|
10
19
|
|
|
11
20
|
let soloditChild: SoloditChildProcess | null = null
|
|
@@ -15,6 +24,9 @@ let isRestarting = false
|
|
|
15
24
|
/** Whether the Solodit MCP server is currently available for tool calls. */
|
|
16
25
|
export let soloditAvailable = false
|
|
17
26
|
|
|
27
|
+
let lifecycleState: LifecycleState = "stopped"
|
|
28
|
+
let lifecycleError: string | undefined
|
|
29
|
+
|
|
18
30
|
const DEFAULT_RESTART_SETTLE_MS = 2_000
|
|
19
31
|
const DEFAULT_RETRY_BASE_DELAY_MS = 1_000
|
|
20
32
|
const HEALTH_CHECK_INTERVAL_MS = 60_000
|
|
@@ -43,10 +55,37 @@ export function _setTestConfig(config: {
|
|
|
43
55
|
if (config.spawnFn !== undefined) spawnFn = config.spawnFn
|
|
44
56
|
}
|
|
45
57
|
|
|
58
|
+
/** Returns the current lifecycle status of the Solodit MCP server. */
|
|
59
|
+
export function getLifecycleStatus(): LifecycleStatus {
|
|
60
|
+
const status: LifecycleStatus = { state: lifecycleState }
|
|
61
|
+
if (lifecycleError) status.error = lifecycleError
|
|
62
|
+
if (soloditChild?.pid !== undefined) status.pid = soloditChild.pid
|
|
63
|
+
return status
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function classifySpawnError(err: unknown, port: number): string {
|
|
67
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
68
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
69
|
+
if (code === "EADDRINUSE") {
|
|
70
|
+
return `Port ${port} already in use — cannot spawn Solodit MCP (EADDRINUSE)`
|
|
71
|
+
}
|
|
72
|
+
if (code === "ENOENT") {
|
|
73
|
+
return `Solodit MCP binary not found — ensure npx and @lyuboslavlyubenov/solodit-mcp are available (ENOENT)`
|
|
74
|
+
}
|
|
75
|
+
return `Failed to spawn Solodit MCP on port ${port}: ${error.message}`
|
|
76
|
+
}
|
|
77
|
+
|
|
46
78
|
function spawnSoloditChild(port: number): SoloditChildProcess {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
79
|
+
try {
|
|
80
|
+
const child = spawnFn(port)
|
|
81
|
+
child.unref()
|
|
82
|
+
return child
|
|
83
|
+
} catch (err) {
|
|
84
|
+
const message = classifySpawnError(err, port)
|
|
85
|
+
lifecycleState = "failed"
|
|
86
|
+
lifecycleError = message
|
|
87
|
+
throw new Error(message)
|
|
88
|
+
}
|
|
50
89
|
}
|
|
51
90
|
|
|
52
91
|
function trackChildExit(child: SoloditChildProcess): void {
|
|
@@ -64,6 +103,16 @@ function trackChildExit(child: SoloditChildProcess): void {
|
|
|
64
103
|
async function restartSoloditMcp(port: number): Promise<boolean> {
|
|
65
104
|
const logger = createLogger()
|
|
66
105
|
|
|
106
|
+
// Pre-check: if existing instance recovered, skip restart entirely
|
|
107
|
+
const preCheck = await checkSoloditHealth(port, true)
|
|
108
|
+
if (preCheck.reachable) {
|
|
109
|
+
soloditAvailable = true
|
|
110
|
+
lifecycleState = "running"
|
|
111
|
+
lifecycleError = undefined
|
|
112
|
+
logger.info("Solodit MCP already healthy — skipping restart")
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
|
|
67
116
|
if (soloditChild) {
|
|
68
117
|
try {
|
|
69
118
|
soloditChild.kill()
|
|
@@ -74,10 +123,16 @@ async function restartSoloditMcp(port: number): Promise<boolean> {
|
|
|
74
123
|
}
|
|
75
124
|
|
|
76
125
|
try {
|
|
126
|
+
lifecycleState = "starting"
|
|
127
|
+
lifecycleError = undefined
|
|
77
128
|
soloditChild = spawnSoloditChild(port)
|
|
78
129
|
trackChildExit(soloditChild)
|
|
79
130
|
} catch (err) {
|
|
80
|
-
|
|
131
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
132
|
+
logger.warn(`Solodit MCP spawn failed: ${message}`)
|
|
133
|
+
lifecycleState = "failed"
|
|
134
|
+
lifecycleError = message
|
|
135
|
+
soloditAvailable = false
|
|
81
136
|
return false
|
|
82
137
|
}
|
|
83
138
|
|
|
@@ -99,10 +154,14 @@ async function restartSoloditMcp(port: number): Promise<boolean> {
|
|
|
99
154
|
|
|
100
155
|
if (result.success) {
|
|
101
156
|
soloditAvailable = true
|
|
157
|
+
lifecycleState = "running"
|
|
158
|
+
lifecycleError = undefined
|
|
102
159
|
logger.info("Solodit MCP restarted successfully")
|
|
103
160
|
return true
|
|
104
161
|
}
|
|
105
162
|
|
|
163
|
+
lifecycleState = "failed"
|
|
164
|
+
lifecycleError = "Solodit MCP not reachable after restart attempts"
|
|
106
165
|
logger.warn("Solodit MCP restart failed — will retry next cycle")
|
|
107
166
|
return false
|
|
108
167
|
}
|
|
@@ -115,6 +174,8 @@ export async function _runMonitoringCycle(port: number): Promise<void> {
|
|
|
115
174
|
if (health.reachable) {
|
|
116
175
|
if (!soloditAvailable) {
|
|
117
176
|
soloditAvailable = true
|
|
177
|
+
lifecycleState = "running"
|
|
178
|
+
lifecycleError = undefined
|
|
118
179
|
logger.info("Solodit MCP recovered — now available")
|
|
119
180
|
}
|
|
120
181
|
} else if (soloditAvailable) {
|
|
@@ -155,6 +216,8 @@ export function _resetSoloditState(): void {
|
|
|
155
216
|
stopSoloditMonitoring()
|
|
156
217
|
soloditAvailable = false
|
|
157
218
|
isRestarting = false
|
|
219
|
+
lifecycleState = "stopped"
|
|
220
|
+
lifecycleError = undefined
|
|
158
221
|
restartSettleMs = DEFAULT_RESTART_SETTLE_MS
|
|
159
222
|
retryBaseDelayMs = DEFAULT_RETRY_BASE_DELAY_MS
|
|
160
223
|
spawnFn = defaultSpawnFn
|
|
@@ -170,17 +233,30 @@ export function _resetSoloditState(): void {
|
|
|
170
233
|
|
|
171
234
|
export async function startSoloditMcp(port: number): Promise<void> {
|
|
172
235
|
const logger = createLogger()
|
|
236
|
+
lifecycleState = "starting"
|
|
237
|
+
lifecycleError = undefined
|
|
173
238
|
|
|
174
239
|
const health = await checkSoloditHealth(port, true)
|
|
175
240
|
if (health.reachable) {
|
|
176
241
|
logger.debug(`Solodit MCP already running on port ${port} — skipping spawn`)
|
|
177
242
|
soloditAvailable = true
|
|
243
|
+
lifecycleState = "running"
|
|
178
244
|
startMonitoring(port)
|
|
179
245
|
return
|
|
180
246
|
}
|
|
181
247
|
|
|
182
|
-
|
|
183
|
-
|
|
248
|
+
try {
|
|
249
|
+
soloditChild = spawnSoloditChild(port)
|
|
250
|
+
trackChildExit(soloditChild)
|
|
251
|
+
} catch (err) {
|
|
252
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
253
|
+
logger.warn(`Solodit MCP startup failed: ${message}`)
|
|
254
|
+
lifecycleState = "failed"
|
|
255
|
+
lifecycleError = message
|
|
256
|
+
soloditAvailable = false
|
|
257
|
+
startMonitoring(port)
|
|
258
|
+
return
|
|
259
|
+
}
|
|
184
260
|
|
|
185
261
|
const deadline = AbortSignal.timeout(5000)
|
|
186
262
|
const delays = [1000, 2000]
|
|
@@ -191,12 +267,15 @@ export async function startSoloditMcp(port: number): Promise<void> {
|
|
|
191
267
|
const healthResult = await checkSoloditHealth(port, true)
|
|
192
268
|
if (healthResult.reachable) {
|
|
193
269
|
soloditAvailable = true
|
|
270
|
+
lifecycleState = "running"
|
|
194
271
|
logger.debug(`Solodit MCP healthy on port ${port}`)
|
|
195
272
|
break
|
|
196
273
|
}
|
|
197
274
|
}
|
|
198
275
|
if (!soloditAvailable) {
|
|
199
|
-
|
|
276
|
+
lifecycleState = "failed"
|
|
277
|
+
lifecycleError = "Solodit MCP not reachable after startup — monitoring will retry"
|
|
278
|
+
logger.warn(lifecycleError)
|
|
200
279
|
}
|
|
201
280
|
|
|
202
281
|
startMonitoring(port)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CanonicalFinding,
|
|
3
|
+
SCHEMA_VERSION,
|
|
4
|
+
type ValidationError,
|
|
5
|
+
validateCanonicalFinding,
|
|
6
|
+
} from "./schemas"
|
|
7
|
+
import type { AuditPhase, Finding, FindingSeverity } from "./types"
|
|
8
|
+
|
|
9
|
+
export interface Diagnostic {
|
|
10
|
+
level: "warn" | "error"
|
|
11
|
+
code: string
|
|
12
|
+
message: string
|
|
13
|
+
field?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type AdapterResult<T> = { data: T; diagnostics: Diagnostic[] }
|
|
17
|
+
|
|
18
|
+
const VALID_SEVERITIES: ReadonlySet<FindingSeverity> = new Set([
|
|
19
|
+
"Critical",
|
|
20
|
+
"High",
|
|
21
|
+
"Medium",
|
|
22
|
+
"Low",
|
|
23
|
+
"Informational",
|
|
24
|
+
])
|
|
25
|
+
const VALID_CONFIDENCES: ReadonlySet<CanonicalFinding["confidence"]> = new Set([
|
|
26
|
+
"High",
|
|
27
|
+
"Medium",
|
|
28
|
+
"Low",
|
|
29
|
+
])
|
|
30
|
+
const VALID_SOURCES: ReadonlySet<CanonicalFinding["source"]> = new Set([
|
|
31
|
+
"slither",
|
|
32
|
+
"manual",
|
|
33
|
+
"pattern",
|
|
34
|
+
"scvd",
|
|
35
|
+
"solodit",
|
|
36
|
+
"fuzz",
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
const KNOWN_INPUT_FIELDS = new Set([
|
|
40
|
+
"id",
|
|
41
|
+
"check",
|
|
42
|
+
"detector",
|
|
43
|
+
"severity",
|
|
44
|
+
"confidence",
|
|
45
|
+
"description",
|
|
46
|
+
"impact",
|
|
47
|
+
"first_markdown_element",
|
|
48
|
+
"file",
|
|
49
|
+
"lines",
|
|
50
|
+
"line",
|
|
51
|
+
"line_start",
|
|
52
|
+
"line_end",
|
|
53
|
+
"source",
|
|
54
|
+
"remediation",
|
|
55
|
+
"exploitReference",
|
|
56
|
+
"provenance",
|
|
57
|
+
"run_id",
|
|
58
|
+
"seq",
|
|
59
|
+
"session_id",
|
|
60
|
+
"tool_call_id",
|
|
61
|
+
"schema_version",
|
|
62
|
+
"elements",
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
66
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeSeverity(value: unknown): CanonicalFinding["severity"] {
|
|
70
|
+
if (typeof value !== "string") return "Informational"
|
|
71
|
+
const lower = value.toLowerCase()
|
|
72
|
+
const map: Record<string, CanonicalFinding["severity"]> = {
|
|
73
|
+
critical: "Critical",
|
|
74
|
+
high: "High",
|
|
75
|
+
medium: "Medium",
|
|
76
|
+
low: "Low",
|
|
77
|
+
informational: "Informational",
|
|
78
|
+
info: "Informational",
|
|
79
|
+
}
|
|
80
|
+
return map[lower] ?? "Informational"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeConfidence(value: unknown): CanonicalFinding["confidence"] {
|
|
84
|
+
if (typeof value !== "string") return "Low"
|
|
85
|
+
const lower = value.toLowerCase()
|
|
86
|
+
const map: Record<string, CanonicalFinding["confidence"]> = {
|
|
87
|
+
high: "High",
|
|
88
|
+
medium: "Medium",
|
|
89
|
+
low: "Low",
|
|
90
|
+
}
|
|
91
|
+
return map[lower] ?? "Low"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeLines(
|
|
95
|
+
value: unknown,
|
|
96
|
+
input: Record<string, unknown>,
|
|
97
|
+
): [number, number] | undefined {
|
|
98
|
+
if (
|
|
99
|
+
Array.isArray(value) &&
|
|
100
|
+
value.length === 2 &&
|
|
101
|
+
typeof value[0] === "number" &&
|
|
102
|
+
typeof value[1] === "number"
|
|
103
|
+
) {
|
|
104
|
+
return [value[0], value[1]]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (typeof input.line === "number") {
|
|
108
|
+
return [input.line, input.line]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (typeof input.line_start === "number" && typeof input.line_end === "number") {
|
|
112
|
+
return [input.line_start, input.line_end]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function slitherElementFileAlias(input: Record<string, unknown>): string | undefined {
|
|
119
|
+
if (!Array.isArray(input.elements) || input.elements.length === 0) {
|
|
120
|
+
return undefined
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const first = input.elements[0]
|
|
124
|
+
if (!isRecord(first)) return undefined
|
|
125
|
+
const sourceMapping = first.source_mapping
|
|
126
|
+
if (!isRecord(sourceMapping)) return undefined
|
|
127
|
+
const filenameRelative = sourceMapping.filename_relative
|
|
128
|
+
return typeof filenameRelative === "string" && filenameRelative.length > 0
|
|
129
|
+
? filenameRelative
|
|
130
|
+
: undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function pushValidationDiagnostics(errors: ValidationError[]): Diagnostic[] {
|
|
134
|
+
return errors.map((error) => ({
|
|
135
|
+
level: "error",
|
|
136
|
+
code: `validation.${error.code}`,
|
|
137
|
+
message: error.message,
|
|
138
|
+
field: error.field,
|
|
139
|
+
}))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function normalizeToCanonicalFinding(
|
|
143
|
+
raw: Finding | Record<string, unknown>,
|
|
144
|
+
runId: string,
|
|
145
|
+
seq: number,
|
|
146
|
+
): AdapterResult<CanonicalFinding> {
|
|
147
|
+
const diagnostics: Diagnostic[] = []
|
|
148
|
+
const input = isRecord(raw) ? raw : {}
|
|
149
|
+
|
|
150
|
+
for (const key of Object.keys(input)) {
|
|
151
|
+
if (!KNOWN_INPUT_FIELDS.has(key)) {
|
|
152
|
+
diagnostics.push({
|
|
153
|
+
level: "warn",
|
|
154
|
+
code: "field.dropped",
|
|
155
|
+
message: `Dropped unknown field: ${key}`,
|
|
156
|
+
field: key,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const check =
|
|
162
|
+
typeof input.check === "string" && input.check.length > 0
|
|
163
|
+
? input.check
|
|
164
|
+
: typeof input.detector === "string" && input.detector.length > 0
|
|
165
|
+
? input.detector
|
|
166
|
+
: ""
|
|
167
|
+
|
|
168
|
+
const description =
|
|
169
|
+
typeof input.description === "string" && input.description.length > 0
|
|
170
|
+
? input.description
|
|
171
|
+
: typeof input.impact === "string" && input.impact.length > 0
|
|
172
|
+
? input.impact
|
|
173
|
+
: typeof input.first_markdown_element === "string" &&
|
|
174
|
+
input.first_markdown_element.length > 0
|
|
175
|
+
? input.first_markdown_element
|
|
176
|
+
: check
|
|
177
|
+
|
|
178
|
+
const file =
|
|
179
|
+
typeof input.file === "string" && input.file.length > 0
|
|
180
|
+
? input.file
|
|
181
|
+
: (slitherElementFileAlias(input) ?? "")
|
|
182
|
+
|
|
183
|
+
const lines = normalizeLines(input.lines, input)
|
|
184
|
+
const severity = normalizeSeverity(input.severity)
|
|
185
|
+
const confidence = normalizeConfidence(input.confidence)
|
|
186
|
+
const source =
|
|
187
|
+
typeof input.source === "string" &&
|
|
188
|
+
VALID_SOURCES.has(input.source as CanonicalFinding["source"])
|
|
189
|
+
? (input.source as CanonicalFinding["source"])
|
|
190
|
+
: "manual"
|
|
191
|
+
|
|
192
|
+
const canonical: CanonicalFinding = {
|
|
193
|
+
id:
|
|
194
|
+
typeof input.id === "string" && input.id.length > 0
|
|
195
|
+
? input.id
|
|
196
|
+
: `${check}:${file}:${lines?.[0] ?? 0}`,
|
|
197
|
+
check,
|
|
198
|
+
severity: VALID_SEVERITIES.has(severity) ? severity : "Informational",
|
|
199
|
+
confidence: VALID_CONFIDENCES.has(confidence) ? confidence : "Low",
|
|
200
|
+
description,
|
|
201
|
+
file,
|
|
202
|
+
lines: lines ?? [0, 0],
|
|
203
|
+
source,
|
|
204
|
+
remediation: typeof input.remediation === "string" ? input.remediation : undefined,
|
|
205
|
+
exploitReference:
|
|
206
|
+
typeof input.exploitReference === "string" ? input.exploitReference : undefined,
|
|
207
|
+
provenance: isRecord(input.provenance)
|
|
208
|
+
? {
|
|
209
|
+
timestamp:
|
|
210
|
+
typeof input.provenance.timestamp === "number"
|
|
211
|
+
? input.provenance.timestamp
|
|
212
|
+
: Date.now(),
|
|
213
|
+
toolVersion:
|
|
214
|
+
typeof input.provenance.toolVersion === "string"
|
|
215
|
+
? input.provenance.toolVersion
|
|
216
|
+
: undefined,
|
|
217
|
+
phase:
|
|
218
|
+
typeof input.provenance.phase === "string"
|
|
219
|
+
? (input.provenance.phase as AuditPhase)
|
|
220
|
+
: undefined,
|
|
221
|
+
}
|
|
222
|
+
: undefined,
|
|
223
|
+
run_id: runId,
|
|
224
|
+
seq,
|
|
225
|
+
schema_version:
|
|
226
|
+
typeof input.schema_version === "string" && input.schema_version.length > 0
|
|
227
|
+
? input.schema_version
|
|
228
|
+
: SCHEMA_VERSION,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const validation = validateCanonicalFinding(canonical)
|
|
232
|
+
if (!validation.success) {
|
|
233
|
+
diagnostics.push(...pushValidationDiagnostics(validation.errors))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { data: canonical, diagnostics }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function normalizeLegacyFindingsArray(
|
|
240
|
+
raw: unknown[],
|
|
241
|
+
runId: string,
|
|
242
|
+
): { findings: CanonicalFinding[]; diagnostics: Diagnostic[] } {
|
|
243
|
+
const findings: CanonicalFinding[] = []
|
|
244
|
+
const diagnostics: Diagnostic[] = []
|
|
245
|
+
|
|
246
|
+
for (const [index, item] of raw.entries()) {
|
|
247
|
+
const normalized = normalizeToCanonicalFinding(isRecord(item) ? item : {}, runId, index + 1)
|
|
248
|
+
diagnostics.push(
|
|
249
|
+
...normalized.diagnostics.map((d) => ({
|
|
250
|
+
...d,
|
|
251
|
+
message: `[index:${index}] ${d.message}`,
|
|
252
|
+
})),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
const hasErrors = normalized.diagnostics.some((d) => d.level === "error")
|
|
256
|
+
if (!hasErrors) {
|
|
257
|
+
findings.push(normalized.data)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { findings, diagnostics }
|
|
262
|
+
}
|