solidity-argus 0.3.6 → 0.5.6
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/AGENTS.md +13 -6
- package/README.md +24 -12
- package/package.json +7 -3
- package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +1 -0
- package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +1 -0
- package/skills/checklists/cyfrin-defi-core/SKILL.md +1 -0
- package/skills/checklists/cyfrin-defi-integrations/SKILL.md +1 -0
- package/skills/checklists/cyfrin-gas/SKILL.md +1 -0
- package/skills/checklists/general-audit/SKILL.md +1 -0
- package/skills/methodology/audit-workflow/SKILL.md +1 -0
- package/skills/methodology/report-template/SKILL.md +1 -0
- package/skills/methodology/severity-classification/SKILL.md +1 -0
- package/skills/protocol-patterns/amm-dex/SKILL.md +1 -0
- package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +1 -0
- package/skills/protocol-patterns/dao-governance/SKILL.md +1 -0
- package/skills/protocol-patterns/lending-borrowing/SKILL.md +1 -0
- package/skills/protocol-patterns/staking-vesting/SKILL.md +1 -0
- package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +0 -50
- package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +0 -63
- package/src/agents/argus-prompt.ts +98 -33
- package/src/agents/pythia-prompt.ts +18 -1
- package/src/agents/scribe-prompt.ts +32 -10
- package/src/agents/sentinel-prompt.ts +19 -0
- package/src/agents/themis-prompt.ts +110 -0
- package/src/cli/commands/doctor.ts +29 -17
- package/src/config/loader.ts +29 -5
- package/src/config/schema.ts +45 -45
- package/src/constants/defaults.ts +1 -0
- package/src/create-hooks.ts +851 -142
- package/src/create-managers.ts +4 -2
- package/src/create-tools.ts +5 -1
- package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
- package/src/features/background-agent/background-manager.ts +32 -5
- package/src/features/error-recovery/tool-error-recovery.ts +1 -0
- package/src/features/persistent-state/audit-state-manager.ts +272 -29
- package/src/features/persistent-state/event-sink.ts +96 -25
- package/src/features/persistent-state/findings-materializer.ts +57 -3
- package/src/features/persistent-state/global-run-index.ts +86 -8
- package/src/features/persistent-state/index.ts +7 -1
- package/src/features/persistent-state/run-finalizer.ts +116 -7
- package/src/features/persistent-state/run-pruner.ts +93 -0
- package/src/hooks/agent-tracker.ts +14 -2
- package/src/hooks/compaction-hook.ts +7 -16
- package/src/hooks/config-handler.ts +83 -29
- package/src/hooks/context-budget.ts +4 -5
- package/src/hooks/event-hook.ts +213 -57
- package/src/hooks/knowledge-sync-hook.ts +2 -3
- package/src/hooks/safe-create-hook.ts +13 -1
- package/src/hooks/system-prompt-hook.ts +20 -39
- package/src/hooks/tool-tracking-hook.ts +606 -326
- package/src/index.ts +15 -1
- package/src/knowledge/scvd-client.ts +2 -4
- package/src/knowledge/scvd-errors.ts +25 -2
- package/src/knowledge/scvd-index.ts +7 -5
- package/src/knowledge/scvd-sync.ts +6 -6
- package/src/managers/types.ts +20 -2
- package/src/shared/agent-names.ts +23 -0
- package/src/shared/audit-artifact-resolver.ts +8 -3
- package/src/shared/audit-phases.ts +12 -0
- package/src/shared/cache-paths.ts +41 -0
- package/src/shared/drop-diagnostics.ts +2 -2
- package/src/shared/forge-errors.ts +31 -0
- package/src/shared/forge-runner.ts +30 -0
- package/src/shared/format-error.ts +3 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/key-tools.ts +39 -0
- package/src/shared/logger.ts +7 -7
- package/src/shared/path-containment.ts +25 -0
- package/src/shared/path-utils.ts +11 -0
- package/src/shared/report-path-resolver.ts +4 -2
- package/src/shared/safe-emit.ts +24 -0
- package/src/shared/token-utils.ts +5 -0
- package/src/shared/type-guards.ts +8 -0
- package/src/shared/validation-constants.ts +52 -0
- package/src/skills/analysis/cluster.ts +1 -114
- package/src/skills/analysis/normalize.ts +2 -114
- package/src/skills/analysis/stopwords.ts +109 -0
- package/src/skills/argus-skill-resolver.ts +6 -3
- package/src/solodit-lifecycle.ts +153 -37
- package/src/state/adapters.ts +60 -66
- package/src/state/finding-aggregation.ts +6 -8
- package/src/state/finding-fingerprint.ts +1 -1
- package/src/state/finding-store.ts +31 -9
- package/src/state/index.ts +1 -1
- package/src/state/projectors.ts +27 -19
- package/src/state/schemas.ts +8 -32
- package/src/state/types.ts +3 -0
- package/src/tools/contract-analyzer-tool.ts +4 -6
- package/src/tools/forge-coverage-tool.ts +10 -35
- package/src/tools/forge-fuzz-tool.ts +21 -51
- package/src/tools/forge-test-tool.ts +25 -47
- package/src/tools/gas-analysis-tool.ts +12 -41
- package/src/tools/pattern-checker-tool.ts +37 -15
- package/src/tools/pattern-loader.ts +18 -4
- package/src/tools/persist-deduped-tool.ts +94 -0
- package/src/tools/proxy-detection-tool.ts +35 -34
- package/src/tools/read-findings-tool.ts +390 -0
- package/src/tools/record-finding-tool.ts +120 -25
- package/src/tools/report-generator-tool.ts +396 -328
- package/src/tools/report-preflight.ts +5 -1
- package/src/tools/slither-tool.ts +55 -16
- package/src/tools/solodit-search-tool.ts +260 -112
- package/src/tools/sync-knowledge-tool.ts +2 -3
- package/src/utils/solidity-parser.ts +39 -24
- package/src/features/migration/index.ts +0 -14
- package/src/features/migration/migration-adapter.ts +0 -151
- package/src/features/migration/parity-telemetry.ts +0 -133
package/src/index.ts
CHANGED
|
@@ -6,14 +6,25 @@ import { createTools } from "./create-tools"
|
|
|
6
6
|
import type { Dispatcher } from "./features/background-agent/background-manager"
|
|
7
7
|
import { createHookGuard } from "./hooks/hook-system"
|
|
8
8
|
import { createPluginInterface } from "./plugin-interface"
|
|
9
|
+
import { createLogger } from "./shared/logger"
|
|
9
10
|
import { startSoloditMcp } from "./solodit-lifecycle"
|
|
11
|
+
import { DEFAULT_SOLODIT_PORT } from "./tools/solodit-search-tool"
|
|
12
|
+
|
|
13
|
+
const logger = createLogger()
|
|
10
14
|
|
|
11
15
|
const ArgusPlugin: Plugin = async (ctx) => {
|
|
12
16
|
const projectDir = ctx.directory ?? process.cwd()
|
|
13
17
|
const config = loadArgusConfig(projectDir)
|
|
14
18
|
|
|
19
|
+
const { ARGUS_PLUGIN_VERSION } = await import("./shared/plugin-metadata")
|
|
20
|
+
console.error(`[argus] v${ARGUS_PLUGIN_VERSION} loaded for ${projectDir}`)
|
|
21
|
+
|
|
15
22
|
if (config.solodit?.enabled !== false) {
|
|
16
|
-
|
|
23
|
+
// MCP bootstrap must not block plugin load; the Solodit search tool falls
|
|
24
|
+
// back to direct HTTP when the local MCP is still coming up.
|
|
25
|
+
void startSoloditMcp(config.solodit?.port ?? DEFAULT_SOLODIT_PORT, {
|
|
26
|
+
waitForHealth: false,
|
|
27
|
+
})
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
const isHookEnabled = createHookGuard(config.disabled_hooks)
|
|
@@ -31,6 +42,9 @@ const ArgusPlugin: Plugin = async (ctx) => {
|
|
|
31
42
|
return taskId
|
|
32
43
|
}
|
|
33
44
|
}
|
|
45
|
+
logger.warn(
|
|
46
|
+
`ctx.task returned unexpected shape (${typeof result}), using fabricated task ID`,
|
|
47
|
+
)
|
|
34
48
|
return `task-${Date.now()}`
|
|
35
49
|
}
|
|
36
50
|
: undefined
|
|
@@ -15,11 +15,9 @@ export interface ScvdStats {
|
|
|
15
15
|
last_updated: string
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
import { isRecord } from "../shared/type-guards"
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
return typeof value === "object" && value !== null
|
|
22
|
-
}
|
|
20
|
+
const DEFAULT_PAGE_SIZE = 100
|
|
23
21
|
|
|
24
22
|
function toStringArray(value: unknown): string[] {
|
|
25
23
|
if (!Array.isArray(value)) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type SyncError = {
|
|
2
2
|
status: "error"
|
|
3
3
|
success: false
|
|
4
|
-
reason: "network" | "api" | "parse"
|
|
4
|
+
reason: "network" | "api" | "parse" | "lock"
|
|
5
5
|
message: string
|
|
6
6
|
error: string
|
|
7
7
|
httpStatus?: number
|
|
@@ -74,6 +74,19 @@ export function createParseError(message: string): SyncError {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
export function createLockError(message: string): SyncError {
|
|
78
|
+
return {
|
|
79
|
+
status: "error",
|
|
80
|
+
success: false,
|
|
81
|
+
reason: "lock",
|
|
82
|
+
message,
|
|
83
|
+
error: message,
|
|
84
|
+
newFindings: 0,
|
|
85
|
+
totalIndexed: 0,
|
|
86
|
+
lastSync: new Date().toISOString(),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
export function createSyncSuccess(
|
|
78
91
|
data: Omit<SyncSuccess, "status" | "success" | "error"> & { attempts?: number },
|
|
79
92
|
): SyncSuccess {
|
|
@@ -84,6 +97,16 @@ export function createSyncSuccess(
|
|
|
84
97
|
}
|
|
85
98
|
}
|
|
86
99
|
|
|
100
|
+
const RETRYABLE_HTTP_STATUSES = new Set([429, 502, 503, 504])
|
|
101
|
+
|
|
87
102
|
export function isRetryableError(outcome: SyncOutcome): boolean {
|
|
88
|
-
|
|
103
|
+
if (outcome.status !== "error") return false
|
|
104
|
+
if (outcome.reason === "network") return true
|
|
105
|
+
if (
|
|
106
|
+
outcome.reason === "api" &&
|
|
107
|
+
outcome.httpStatus &&
|
|
108
|
+
RETRYABLE_HTTP_STATUSES.has(outcome.httpStatus)
|
|
109
|
+
)
|
|
110
|
+
return true
|
|
111
|
+
return false
|
|
89
112
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRecord } from "../shared/type-guards"
|
|
1
2
|
import type { ScvdFinding } from "./scvd-client"
|
|
2
3
|
|
|
3
4
|
export interface ScvdIndexEntry {
|
|
@@ -127,10 +128,6 @@ export async function saveIndex(index: ScvdIndex, filePath: string): Promise<voi
|
|
|
127
128
|
renameSync(tmpPath, filePath)
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
131
|
-
return typeof value === "object" && value !== null
|
|
132
|
-
}
|
|
133
|
-
|
|
134
131
|
function parseStringArray(value: unknown): string[] {
|
|
135
132
|
if (!Array.isArray(value)) {
|
|
136
133
|
return []
|
|
@@ -191,7 +188,12 @@ export async function loadIndex(filePath: string): Promise<ScvdIndex | null> {
|
|
|
191
188
|
return null
|
|
192
189
|
}
|
|
193
190
|
|
|
194
|
-
|
|
191
|
+
let raw: unknown
|
|
192
|
+
try {
|
|
193
|
+
raw = (await file.json()) as unknown
|
|
194
|
+
} catch {
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
195
197
|
|
|
196
198
|
if (!isRecord(raw)) {
|
|
197
199
|
return null
|
|
@@ -4,6 +4,7 @@ import type { ScvdClient } from "./scvd-client"
|
|
|
4
4
|
import { ScvdApiError, ScvdNetworkError } from "./scvd-client"
|
|
5
5
|
import {
|
|
6
6
|
createApiError,
|
|
7
|
+
createLockError,
|
|
7
8
|
createNetworkError,
|
|
8
9
|
createParseError,
|
|
9
10
|
createSyncSuccess,
|
|
@@ -39,11 +40,10 @@ function buildErrorResult(error: unknown): SyncError {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
function shouldRetrySyncError(error: unknown): boolean {
|
|
42
|
-
if (
|
|
43
|
-
return
|
|
43
|
+
if (error instanceof ScvdNetworkError || error instanceof ScvdApiError) {
|
|
44
|
+
return isRetryableError(buildErrorResult(error))
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
-
return isRetryableError(buildErrorResult(error))
|
|
46
|
+
return false
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
function errorReasonFromResult(result: SyncError): string {
|
|
@@ -111,7 +111,7 @@ export async function syncAll(client: ScvdClient, indexPath: string): Promise<Sy
|
|
|
111
111
|
const logger = createLogger()
|
|
112
112
|
|
|
113
113
|
if (!acquireSyncLock()) {
|
|
114
|
-
return
|
|
114
|
+
return createLockError("Sync already in progress")
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
logger.debug("[sync] starting", "source=scvd mode=full")
|
|
@@ -144,7 +144,7 @@ export async function syncIncremental(client: ScvdClient, indexPath: string): Pr
|
|
|
144
144
|
const logger = createLogger()
|
|
145
145
|
|
|
146
146
|
if (!acquireSyncLock()) {
|
|
147
|
-
return
|
|
147
|
+
return createLockError("Sync already in progress")
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
logger.debug("[sync] starting", "source=scvd mode=incremental")
|
package/src/managers/types.ts
CHANGED
|
@@ -46,13 +46,25 @@ export interface BackgroundManager {
|
|
|
46
46
|
*/
|
|
47
47
|
export interface AuditStateManager {
|
|
48
48
|
/**
|
|
49
|
-
*
|
|
49
|
+
* Bind this manager to a specific OpenCode session.
|
|
50
|
+
* After binding, save/load operate on a session-scoped state file
|
|
51
|
+
* (.argus/sessions/state-{sessionId}.json) instead of the shared file.
|
|
52
|
+
* This prevents multi-instance contamination.
|
|
53
|
+
* @param sessionId - The OpenCode session ID (e.g., "ses_abc123")
|
|
54
|
+
*/
|
|
55
|
+
bindSession(sessionId: string): void
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Load audit state from persistent storage.
|
|
59
|
+
* If bound to a session, tries the session-scoped file first,
|
|
60
|
+
* then falls back to the most recent state file from any session.
|
|
50
61
|
* @returns Promise resolving to AuditState or null if not found
|
|
51
62
|
*/
|
|
52
63
|
load(): Promise<AuditState | null>
|
|
53
64
|
|
|
54
65
|
/**
|
|
55
|
-
* Save audit state to persistent storage
|
|
66
|
+
* Save audit state to persistent storage.
|
|
67
|
+
* Writes to the session-scoped file if bound, otherwise the shared file.
|
|
56
68
|
* @param state - The AuditState to persist
|
|
57
69
|
*/
|
|
58
70
|
save(state: AuditState): Promise<void>
|
|
@@ -78,6 +90,12 @@ export interface AuditStateManager {
|
|
|
78
90
|
* Archive current state (if meaningful) then reset
|
|
79
91
|
*/
|
|
80
92
|
archive(): Promise<void>
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Dispose the state manager, flushing pending state to disk and releasing resources.
|
|
96
|
+
* Safe to call multiple times; subsequent calls are no-ops.
|
|
97
|
+
*/
|
|
98
|
+
dispose(): Promise<void>
|
|
81
99
|
}
|
|
82
100
|
|
|
83
101
|
/**
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const ARGUS_ORCHESTRATOR: ReadonlySet<string> = new Set(["argus"])
|
|
2
|
+
export const ARGUS_SUBAGENTS: ReadonlySet<string> = new Set([
|
|
3
|
+
"sentinel",
|
|
4
|
+
"pythia",
|
|
5
|
+
"scribe",
|
|
6
|
+
"themis",
|
|
7
|
+
])
|
|
8
|
+
export const ARGUS_FAMILY: ReadonlySet<string> = new Set([
|
|
9
|
+
...ARGUS_ORCHESTRATOR,
|
|
10
|
+
...ARGUS_SUBAGENTS,
|
|
11
|
+
])
|
|
12
|
+
|
|
13
|
+
export function isArgusFamily(agent: string): boolean {
|
|
14
|
+
return ARGUS_FAMILY.has(agent)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isOrchestratorAgent(agent: string): boolean {
|
|
18
|
+
return ARGUS_ORCHESTRATOR.has(agent)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isSubagent(agent: string): boolean {
|
|
22
|
+
return ARGUS_SUBAGENTS.has(agent)
|
|
23
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join } from "node:path"
|
|
1
|
+
import { basename, join } from "node:path"
|
|
2
2
|
import { defaultRootResolver } from "./path-root-resolver"
|
|
3
3
|
|
|
4
4
|
export class ArtifactResolverError extends Error {
|
|
@@ -15,6 +15,9 @@ export interface AuditArtifactPaths {
|
|
|
15
15
|
journalFile: string
|
|
16
16
|
/** {projectDir}/.argus/runs/{runId}/findings.json */
|
|
17
17
|
findingsFile: string
|
|
18
|
+
reportInputFile: string
|
|
19
|
+
/** {projectDir}/.argus/runs/{runId}/deduped-findings.json */
|
|
20
|
+
dedupedFindingsFile: string
|
|
18
21
|
/** {projectDir}/.argus/reports */
|
|
19
22
|
reportDir: string
|
|
20
23
|
/** {projectDir}/.argus/runs/{runId}/evidence */
|
|
@@ -53,6 +56,8 @@ export function createAuditArtifactResolver(
|
|
|
53
56
|
stateFile: join(writeRoot, "argus-state.json"),
|
|
54
57
|
journalFile: join(runDir, "events.jsonl"),
|
|
55
58
|
findingsFile: join(runDir, "findings.json"),
|
|
59
|
+
reportInputFile: join(runDir, "report-input.json"),
|
|
60
|
+
dedupedFindingsFile: join(runDir, "deduped-findings.json"),
|
|
56
61
|
reportDir: join(writeRoot, "reports"),
|
|
57
62
|
evidenceDir: join(runDir, "evidence"),
|
|
58
63
|
archiveDir: join(writeRoot, "archives"),
|
|
@@ -66,10 +71,10 @@ export function createAuditArtifactResolver(
|
|
|
66
71
|
return cachedPaths
|
|
67
72
|
},
|
|
68
73
|
reportFilePath(filename: string): string {
|
|
69
|
-
return join(cachedPaths.reportDir, filename)
|
|
74
|
+
return join(cachedPaths.reportDir, basename(filename))
|
|
70
75
|
},
|
|
71
76
|
evidenceFilePath(filename: string): string {
|
|
72
|
-
return join(cachedPaths.evidenceDir, filename)
|
|
77
|
+
return join(cachedPaths.evidenceDir, basename(filename))
|
|
73
78
|
},
|
|
74
79
|
}
|
|
75
80
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { dirname, join } from "node:path"
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CACHE_DIR = join(homedir(), ".cache", "solidity-argus")
|
|
5
|
+
|
|
6
|
+
function normalizeOverride(value: string | undefined): string | null {
|
|
7
|
+
if (!value) {
|
|
8
|
+
return null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const trimmed = value.trim()
|
|
12
|
+
return trimmed.length > 0 ? trimmed : null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getArgusCacheDir(): string {
|
|
16
|
+
return normalizeOverride(process.env.ARGUS_CACHE_DIR) ?? DEFAULT_CACHE_DIR
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getArgusLogFile(): string {
|
|
20
|
+
return normalizeOverride(process.env.ARGUS_LOG_FILE) ?? join(getArgusCacheDir(), "argus.log")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getArgusLogDir(): string {
|
|
24
|
+
return dirname(getArgusLogFile())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getScvdIndexPath(): string {
|
|
28
|
+
return join(getArgusCacheDir(), "scvd-index.json")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getTrailOfBitsCacheDir(): string {
|
|
32
|
+
return join(getArgusCacheDir(), "trailofbits-skills")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getGlobalRunIndexDir(): string {
|
|
36
|
+
return join(getArgusCacheDir(), "runs")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getGlobalRunIndexFile(): string {
|
|
40
|
+
return join(getGlobalRunIndexDir(), "index.jsonl")
|
|
41
|
+
}
|
|
@@ -76,9 +76,9 @@ export function createDropDiagnosticsCollector(
|
|
|
76
76
|
|
|
77
77
|
const logMsg = `[${source}${tool ? `:${tool}` : ""}] ${code}${field ? ` (field: ${field})` : ""}: ${message}`
|
|
78
78
|
if (level === "error") {
|
|
79
|
-
logger.
|
|
79
|
+
logger.error(logMsg)
|
|
80
80
|
} else {
|
|
81
|
-
logger.
|
|
81
|
+
logger.warn(logMsg)
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ToolContext } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
export const FOUNDRY_NOT_FOUND_MESSAGE =
|
|
4
|
+
"Foundry not found. Install: curl -L https://foundry.paradigm.xyz | bash"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Classify a caught error from a forge command execution into a user-facing
|
|
8
|
+
* error string. Returns `undefined` when the error is not a recognized
|
|
9
|
+
* forge-specific failure and should be handled by the caller.
|
|
10
|
+
*/
|
|
11
|
+
export function classifyForgeError(
|
|
12
|
+
error: unknown,
|
|
13
|
+
context: ToolContext,
|
|
14
|
+
toolLabel: string,
|
|
15
|
+
): string | undefined {
|
|
16
|
+
if (context.abort.aborted || (error instanceof DOMException && error.name === "AbortError")) {
|
|
17
|
+
return `${toolLabel} aborted`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const maybeError = error as Error & { code?: string }
|
|
21
|
+
|
|
22
|
+
if (maybeError.code === "ENOENT") {
|
|
23
|
+
return FOUNDRY_NOT_FOUND_MESSAGE
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (maybeError.code === "ETIMEDOUT" || maybeError.message?.toLowerCase().includes("timed out")) {
|
|
27
|
+
return `${toolLabel} timed out`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return undefined
|
|
31
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type ForgeCommandResult = {
|
|
2
|
+
stdout: string
|
|
3
|
+
stderr: string
|
|
4
|
+
exitCode: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function runForgeCommand(
|
|
8
|
+
command: string[],
|
|
9
|
+
options: { signal?: AbortSignal; cwd?: string; env?: Record<string, string> },
|
|
10
|
+
): Promise<ForgeCommandResult> {
|
|
11
|
+
const child = Bun.spawn(command, {
|
|
12
|
+
cwd: options.cwd,
|
|
13
|
+
stdout: "pipe",
|
|
14
|
+
stderr: "pipe",
|
|
15
|
+
signal: options.signal,
|
|
16
|
+
env: options.env,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
20
|
+
child.exited,
|
|
21
|
+
new Response(child.stdout).text(),
|
|
22
|
+
new Response(child.stderr).text(),
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
stdout,
|
|
27
|
+
stderr,
|
|
28
|
+
exitCode,
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/shared/index.ts
CHANGED
|
@@ -5,6 +5,15 @@ export {
|
|
|
5
5
|
createAuditArtifactResolver,
|
|
6
6
|
} from "./audit-artifact-resolver"
|
|
7
7
|
export { extractContractNames, hasBinary, parseSolcVersion } from "./binary-utils"
|
|
8
|
+
export {
|
|
9
|
+
getArgusCacheDir,
|
|
10
|
+
getArgusLogDir,
|
|
11
|
+
getArgusLogFile,
|
|
12
|
+
getGlobalRunIndexDir,
|
|
13
|
+
getGlobalRunIndexFile,
|
|
14
|
+
getScvdIndexPath,
|
|
15
|
+
getTrailOfBitsCacheDir,
|
|
16
|
+
} from "./cache-paths"
|
|
8
17
|
export { deepMerge } from "./deep-merge"
|
|
9
18
|
export {
|
|
10
19
|
type ConfigFileInfo,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical list of key audit tools and mappings used by the reporting gate
|
|
3
|
+
* and report preflight to determine which tools must complete before report
|
|
4
|
+
* generation is allowed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Maps full tool names to short names used in the reporting gate. */
|
|
8
|
+
export const TOOL_SHORT_NAMES: Record<string, string> = {
|
|
9
|
+
argus_slither_analyze: "slither",
|
|
10
|
+
argus_forge_test: "forge-test",
|
|
11
|
+
argus_check_patterns: "patterns",
|
|
12
|
+
argus_solodit_search: "solodit",
|
|
13
|
+
argus_analyze_contract: "analyzer",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The short names of tools that must complete before report generation. */
|
|
17
|
+
export const KEY_TOOLS = ["slither", "forge-test", "patterns", "solodit", "analyzer"]
|
|
18
|
+
|
|
19
|
+
/** Maps unavailable-tool short names to their KEY_TOOLS counterpart. */
|
|
20
|
+
export const UNAVAILABLE_TO_KEY_TOOL: Record<string, string> = {
|
|
21
|
+
slither: "slither",
|
|
22
|
+
forge: "forge-test",
|
|
23
|
+
solodit: "solodit",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compute which key tools have not yet been executed, excusing any that are
|
|
28
|
+
* declared unavailable.
|
|
29
|
+
*/
|
|
30
|
+
export function computeMissingKeyTools(
|
|
31
|
+
toolsExecuted: Array<{ tool: string }>,
|
|
32
|
+
unavailableTools?: string[],
|
|
33
|
+
): string[] {
|
|
34
|
+
const executedShortNames = new Set(toolsExecuted.map((t) => TOOL_SHORT_NAMES[t.tool] ?? t.tool))
|
|
35
|
+
const excused = new Set(
|
|
36
|
+
(unavailableTools ?? []).map((t) => UNAVAILABLE_TO_KEY_TOOL[t]).filter(Boolean),
|
|
37
|
+
)
|
|
38
|
+
return KEY_TOOLS.filter((t) => !executedShortNames.has(t) && !excused.has(t))
|
|
39
|
+
}
|
package/src/shared/logger.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { appendFileSync, existsSync, mkdirSync } from "node:fs"
|
|
2
|
-
import {
|
|
3
|
-
import { join } from "node:path"
|
|
2
|
+
import { getArgusLogDir, getArgusLogFile } from "./cache-paths"
|
|
4
3
|
|
|
5
4
|
export interface LoggerConfig {
|
|
6
5
|
debug?: boolean
|
|
@@ -15,12 +14,13 @@ export interface Logger {
|
|
|
15
14
|
|
|
16
15
|
type LogSink = (line: string) => void
|
|
17
16
|
|
|
18
|
-
const LOG_DIR =
|
|
19
|
-
const LOG_FILE =
|
|
17
|
+
const LOG_DIR = getArgusLogDir()
|
|
18
|
+
const LOG_FILE = getArgusLogFile()
|
|
20
19
|
|
|
21
20
|
function ensureLogDir(): void {
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
const logDir = getArgusLogDir()
|
|
22
|
+
if (!existsSync(logDir)) {
|
|
23
|
+
mkdirSync(logDir, { recursive: true })
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -51,7 +51,7 @@ function createFileSink(): LogSink {
|
|
|
51
51
|
dirReady = true
|
|
52
52
|
}
|
|
53
53
|
try {
|
|
54
|
-
appendFileSync(
|
|
54
|
+
appendFileSync(getArgusLogFile(), line)
|
|
55
55
|
} catch {
|
|
56
56
|
// if we can't write logs, we don't crash the plugin
|
|
57
57
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { relative, resolve } from "node:path"
|
|
2
|
+
|
|
3
|
+
export function isContained(child: string, root: string): boolean {
|
|
4
|
+
const resolvedChild = resolve(root, child)
|
|
5
|
+
const resolvedRoot = resolve(root)
|
|
6
|
+
const rel = relative(resolvedRoot, resolvedChild)
|
|
7
|
+
return !rel.startsWith("..")
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function assertContained(child: string, root: string): string {
|
|
11
|
+
const resolvedChild = resolve(root, child)
|
|
12
|
+
if (!isContained(resolvedChild, root)) {
|
|
13
|
+
throw new Error(`Path "${child}" resolves outside project root "${root}"`)
|
|
14
|
+
}
|
|
15
|
+
return resolvedChild
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validateUrlScheme(url: string): boolean {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = new URL(url)
|
|
21
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
|
22
|
+
} catch {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { isAbsolute, normalize, relative } from "node:path"
|
|
2
|
+
|
|
3
|
+
export function normalizeFilePath(filePath: string, projectDir: string): string {
|
|
4
|
+
if (!filePath) return ""
|
|
5
|
+
const normalized = normalize(filePath)
|
|
6
|
+
if (isAbsolute(normalized)) {
|
|
7
|
+
const rel = relative(projectDir, normalized)
|
|
8
|
+
return rel.startsWith("..") ? normalized : rel
|
|
9
|
+
}
|
|
10
|
+
return normalized.replace(/^\.\//, "")
|
|
11
|
+
}
|
|
@@ -37,11 +37,12 @@ export function formatReportDate(date: Date): string {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export function sanitizeContractName(name: string): string {
|
|
40
|
-
|
|
40
|
+
const sanitized = name
|
|
41
41
|
.replace(/\s+/g, "-")
|
|
42
42
|
.replace(/[^a-zA-Z0-9-]/g, "")
|
|
43
43
|
.replace(/-+/g, "-")
|
|
44
44
|
.replace(/^-|-$/g, "")
|
|
45
|
+
return sanitized || "unnamed-contract"
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export function resolveReportPath(options: ReportPathOptions): ResolvedReportPath {
|
|
@@ -57,7 +58,8 @@ export function resolveReportPath(options: ReportPathOptions): ResolvedReportPat
|
|
|
57
58
|
const resolvedDate = date ?? new Date()
|
|
58
59
|
const dateStr = formatReportDate(resolvedDate)
|
|
59
60
|
const sanitizedName = sanitizeContractName(contractName)
|
|
60
|
-
const
|
|
61
|
+
const runIdSuffix = runId ? `-${runId.substring(0, 8)}` : ""
|
|
62
|
+
const filename = `${sanitizedName}-security-audit-${dateStr}${runIdSuffix}.md`
|
|
61
63
|
const filePath = join(outputDir, filename)
|
|
62
64
|
const canonicalId = runId ?? filename
|
|
63
65
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { EventSink } from "../features/persistent-state/event-sink"
|
|
2
|
+
import type { AuditEvent } from "../state/schemas"
|
|
3
|
+
import { formatError } from "./format-error"
|
|
4
|
+
import { createLogger } from "./logger"
|
|
5
|
+
|
|
6
|
+
const logger = createLogger()
|
|
7
|
+
|
|
8
|
+
export async function safeEmitToSink(
|
|
9
|
+
sink: EventSink | null,
|
|
10
|
+
event: AuditEvent,
|
|
11
|
+
options?: { failFast?: boolean },
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
if (!sink) return
|
|
14
|
+
try {
|
|
15
|
+
await sink.append(event)
|
|
16
|
+
} catch (error) {
|
|
17
|
+
const message = `Failed to emit ${event.type} event to sink: ${formatError(error)}`
|
|
18
|
+
logger.error(message)
|
|
19
|
+
|
|
20
|
+
if (options?.failFast) {
|
|
21
|
+
throw new Error(message)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/** Type guard: returns true when value is a non-empty string (after trimming). */
|
|
6
|
+
export function isNonEmptyString(value: unknown): value is string {
|
|
7
|
+
return typeof value === "string" && value.trim().length > 0
|
|
8
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ArgusAgentName, Finding, FindingSeverity } from "../state/types"
|
|
2
|
+
import { ARGUS_FAMILY } from "./agent-names"
|
|
3
|
+
|
|
4
|
+
export function countBySeverity(findings: Finding[]): Record<FindingSeverity, number> {
|
|
5
|
+
const counts: Record<FindingSeverity, number> = {
|
|
6
|
+
Critical: 0,
|
|
7
|
+
High: 0,
|
|
8
|
+
Medium: 0,
|
|
9
|
+
Low: 0,
|
|
10
|
+
Informational: 0,
|
|
11
|
+
}
|
|
12
|
+
for (const finding of findings) {
|
|
13
|
+
counts[finding.severity]++
|
|
14
|
+
}
|
|
15
|
+
return counts
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const VALID_SEVERITIES: ReadonlySet<FindingSeverity> = new Set([
|
|
19
|
+
"Critical",
|
|
20
|
+
"High",
|
|
21
|
+
"Medium",
|
|
22
|
+
"Low",
|
|
23
|
+
"Informational",
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
export const VALID_CONFIDENCES: ReadonlySet<Finding["confidence"]> = new Set([
|
|
27
|
+
"High",
|
|
28
|
+
"Medium",
|
|
29
|
+
"Low",
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
export const VALID_SOURCES: ReadonlySet<Finding["source"]> = new Set([
|
|
33
|
+
"slither",
|
|
34
|
+
"manual",
|
|
35
|
+
"pattern",
|
|
36
|
+
"scvd",
|
|
37
|
+
"solodit",
|
|
38
|
+
"fuzz",
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
export const VALID_AGENTS: ReadonlySet<ArgusAgentName> = new Set([
|
|
42
|
+
...ARGUS_FAMILY,
|
|
43
|
+
"unknown",
|
|
44
|
+
] as ArgusAgentName[])
|
|
45
|
+
|
|
46
|
+
export const SEVERITY_RANK: Record<FindingSeverity, number> = {
|
|
47
|
+
Critical: 0,
|
|
48
|
+
High: 1,
|
|
49
|
+
Medium: 2,
|
|
50
|
+
Low: 3,
|
|
51
|
+
Informational: 4,
|
|
52
|
+
}
|