@gpzhang2001/sharpkit-reporting 0.2.1 → 0.2.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gpzhang2001/sharpkit-reporting",
3
3
  "description": "Vulnerability/dependency reporting tools + sharpkit_runs artifacts (run.json, vuln markdown, csv/json, SARIF 2.1.0)",
4
- "version": "0.2.1",
4
+ "version": "0.2.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
package/src/index.ts CHANGED
@@ -11,8 +11,9 @@
11
11
  * @module @gpzhang2001/sharpkit-reporting
12
12
  */
13
13
 
14
+ import { existsSync } from 'node:fs'
14
15
  import { mkdir, readFile } from 'node:fs/promises'
15
- import { join, resolve } from 'node:path'
16
+ import { isAbsolute, join, resolve } from 'node:path'
16
17
  import type { Context } from '@deepseek-ai/cordis'
17
18
  import type Schema from '@deepseek-ai/schemastery'
18
19
  import z from '@deepseek-ai/schemastery'
@@ -245,8 +246,11 @@ const DYNAMIC_ONLY_UPDATE_FIELDS = new Set(['endpoint', 'method', 'poc_descripti
245
246
 
246
247
  export interface ReportingHandle {
247
248
  readonly state: ReportState
249
+ /** Directory of the most recently resolved run (sync view; resolve via {@link runDirFor} for accuracy). */
248
250
  readonly runDir: string
249
- finishScan(sections: { readonly executiveSummary: string; readonly methodology: string; readonly technicalAnalysis: string; readonly recommendations: string }, status?: string): Promise<void>
251
+ /** Resolve (memoize per scan) this session's run directory see apply() for the rules. */
252
+ runDirFor(session: unknown): Promise<string>
253
+ finishScan(sections: { readonly executiveSummary: string; readonly methodology: string; readonly technicalAnalysis: string; readonly recommendations: string }, status?: string, extras?: Readonly<Record<string, unknown>>): Promise<void>
250
254
  writeNow(): Promise<void>
251
255
  readRaw(relative: string): Promise<string>
252
256
  }
@@ -364,42 +368,178 @@ interface GetArgs {
364
368
 
365
369
  export function apply(ctx: Context, config: Config = {}): ReportingHandle {
366
370
  const runsRoot = config.runsRoot ?? 'sharpkit_runs'
367
- const runName = config.runName ?? `pentest-${Math.random().toString(16).slice(2, 6)}`
368
- const runDir = resolve(join(resolve(runsRoot), runName))
369
- const state = new ReportState({ runName })
371
+ const fallbackRunName = config.runName ?? `pentest-${Math.random().toString(16).slice(2, 6)}`
372
+ const state = new ReportState({ runName: fallbackRunName })
370
373
  const toolVersion = config.toolVersion ?? '0.1.0'
371
374
  /** scan_results block, set by finishScan and appended to run.json. */
372
375
  let scanResults: Record<string, unknown> | undefined
373
376
 
374
- // llm_usage ledger: session assistant/message usage events are the only
375
- // host-side token source (dsh emits no cost events). Counts every session
376
- // in this host process — single-scan deployments are exact; concurrent
377
- // scans in one host share the ledger (session log stays authoritative).
378
- const usageLedger = { requests: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0 }
379
- void ctx.on('session/event', (_session: unknown, event: unknown) => {
377
+ // ---- Run-directory resolution: artifacts follow the dsh working directory ----
378
+
379
+ /**
380
+ * Structural view of the dsh Session behind a tool execution: `agent.session`
381
+ * (the same seam the approval gates use) carries a `header` with the session
382
+ * id, its absolute working directory, and for subagent children — the
383
+ * owning root session. A scan's identity is the ROOT session id, so the
384
+ * whole subagent tree shares one run directory; the header cwd is the base
385
+ * a user's own writes would land in, which is where they expect artifacts.
386
+ */
387
+ interface SessionLike { header?: { id?: unknown; cwd?: unknown; parentSession?: unknown } }
388
+ const scanFactsOf = (session: unknown): { id: string; key: string; cwd: string } => {
389
+ const header = (session as SessionLike | undefined)?.header
390
+ const id = typeof header?.id === 'string' ? header.id : ''
391
+ const parent = typeof header?.parentSession === 'string' ? header.parentSession : ''
392
+ const cwd = typeof header?.cwd === 'string' && header.cwd !== '' ? header.cwd : ''
393
+ return { id, key: parent !== '' ? parent : id, cwd }
394
+ }
395
+ const sessionOf = (exec: unknown): unknown => (exec as { agent?: { session?: unknown } } | undefined)?.agent?.session
396
+ const AGENTLESS = ':agentless'
397
+ /** Per-scan memo: resolved run dir by scan key (root session id). */
398
+ const resolvedRunDirs = new Map<string, string>()
399
+ /** The session noted by the most recent tool execute (one scan per host at a time). */
400
+ let activeSession: unknown
401
+ /** sessionId → scan key: lets the usage ledger attribute assistant messages to the owning scan. */
402
+ const scanKeyOfSession = new Map<string, string>()
403
+ const noteScanKey = (session: unknown): void => {
404
+ const facts = scanFactsOf(session)
405
+ if (facts.id === '') return
406
+ const previous = scanKeyOfSession.get(facts.id)
407
+ scanKeyOfSession.set(facts.id, facts.key)
408
+ // First sighting of a subagent: fold any orphan usage account accumulated
409
+ // under its own id (messages that predate its first tool execute) into the
410
+ // root scan's account, so a scan's llm_usage stays whole.
411
+ if (previous !== facts.key && facts.key !== facts.id) {
412
+ const orphan = usageByScan.get(facts.id)
413
+ if (orphan !== undefined) {
414
+ const root = usageAccountOf(facts.key)
415
+ root.requests += orphan.requests
416
+ root.inputTokens += orphan.inputTokens
417
+ root.outputTokens += orphan.outputTokens
418
+ root.totalTokens += orphan.totalTokens
419
+ usageByScan.delete(facts.id)
420
+ }
421
+ }
422
+ }
423
+ const noteExec = (rawExec: never): void => {
424
+ const session = sessionOf(rawExec)
425
+ if (session !== undefined) {
426
+ activeSession = session
427
+ noteScanKey(session)
428
+ }
429
+ }
430
+
431
+ /** Occupancy facts of an existing run dir (best-effort read of its run.json). */
432
+ const runInfoOf = async (dir: string): Promise<{ completed: boolean; scanKey: string | null }> => {
433
+ try {
434
+ const raw = JSON.parse(await readFile(join(dir, 'run.json'), 'utf8')) as {
435
+ status?: unknown
436
+ session_id?: unknown
437
+ scan_results?: { scan_completed?: unknown } | undefined
438
+ }
439
+ const completed = raw['status'] === 'completed' || raw['scan_results']?.['scan_completed'] === true
440
+ const scanKey = typeof raw['session_id'] === 'string' ? raw['session_id'] : null
441
+ return { completed, scanKey }
442
+ } catch {
443
+ return { completed: false, scanKey: null }
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Resolve this scan's run directory, memoized per scan key:
449
+ * - an absolute `runsRoot` pins the base (tests, explicit configs — unchanged);
450
+ * - the default relative root resolves against the session header cwd,
451
+ * falling back to process.cwd() for agentless calls;
452
+ * - the name is the configured `runName`, else `pentest-<scan key short>`
453
+ * (unique per scan, stable across restarts);
454
+ * - a fresh scan NEVER overwrites a completed or foreign run: the name gains
455
+ * a -2/-3… suffix; the same scan resuming its own incomplete run reuses
456
+ * its directory (crash resume).
457
+ */
458
+ const runDirFor = async (session: unknown): Promise<string> => {
459
+ if (session !== undefined) {
460
+ activeSession = session
461
+ noteScanKey(session)
462
+ }
463
+ const facts = scanFactsOf(session)
464
+ const key = facts.key !== '' ? facts.key : AGENTLESS
465
+ const memo = resolvedRunDirs.get(key)
466
+ if (memo !== undefined) return memo
467
+ const base = isAbsolute(runsRoot) ? resolve(runsRoot) : resolve(facts.cwd !== '' ? facts.cwd : process.cwd(), runsRoot)
468
+ const nameBase = config.runName ?? (facts.key !== '' ? `pentest-${facts.key.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8)}` : fallbackRunName)
469
+ let name = nameBase
470
+ let dir = resolve(join(base, name))
471
+ for (let suffix = 2; existsSync(dir); suffix++) {
472
+ const info = await runInfoOf(dir)
473
+ // Same scan resuming its own incomplete run reuses the directory;
474
+ // anything else (a completed run, a stale residue, a foreign scan) is kept.
475
+ if (!info.completed && info.scanKey === key && key !== AGENTLESS) break
476
+ if (suffix === 2) {
477
+ ctx.logger.warn(`pentest-reporting: run dir '${name}' already holds ${info.completed ? 'a completed run' : 'another run'}; the new run goes to '${nameBase}-<n>'`)
478
+ }
479
+ name = `${nameBase}-${suffix}`
480
+ dir = resolve(join(base, name))
481
+ }
482
+ resolvedRunDirs.set(key, dir)
483
+ if (state.runName !== name) state.runName = name
484
+ // Each newly resolved run dir gets a fresh run id: the state is shared for
485
+ // multi-round continuation, but two rounds' run.json must not show the
486
+ // same run_id (2026-09-22: both read run-22f45fc7).
487
+ state.reseatRunId()
488
+ return dir
489
+ }
490
+
491
+ /** Resolve the directory for the last-noted session (tools set it at execute start). */
492
+ const currentRunDir = (): Promise<string> => runDirFor(activeSession)
493
+
494
+ // llm_usage ledger, accounted PER SCAN KEY (root session): session
495
+ // assistant/message usage events are the only host-side token source (dsh
496
+ // emits no cost events). A second scan in the same host process starts its
497
+ // own account instead of inheriting the previous scan's tokens
498
+ // (2026-09-22: round 2's run.json carried round 1's whole 78M ledger).
499
+ interface UsageAccount { requests: number; inputTokens: number; outputTokens: number; totalTokens: number }
500
+ const usageByScan = new Map<string, UsageAccount>()
501
+ const usageAccountOf = (key: string): UsageAccount => {
502
+ let account = usageByScan.get(key)
503
+ if (account === undefined) {
504
+ account = { requests: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0 }
505
+ usageByScan.set(key, account)
506
+ }
507
+ return account
508
+ }
509
+ void ctx.on('session/event', (session: { id?: unknown } | undefined, event: unknown) => {
380
510
  const record = event as { type?: string; data?: { usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } } }
381
511
  if (record.type !== 'assistant/message') return
382
512
  const usage = record.data?.usage
383
513
  if (usage === undefined || usage === null) return
384
- usageLedger.requests += 1
385
- usageLedger.inputTokens += usage.inputTokens ?? 0
386
- usageLedger.outputTokens += usage.outputTokens ?? 0
387
- usageLedger.totalTokens += usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
388
- })
389
- const llmUsageRecord = (): Record<string, unknown> => ({
390
- requests: usageLedger.requests,
391
- input_tokens: usageLedger.inputTokens,
392
- output_tokens: usageLedger.outputTokens,
393
- total_tokens: usageLedger.totalTokens,
394
- // dsh session events carry tokens only; cost stays null (parity deviation
395
- // recorded in the manual — strix estimates cost via LiteLLM callbacks).
396
- cost: null,
397
- agents: [],
514
+ const sessionId = typeof session?.id === 'string' ? session.id : ''
515
+ if (sessionId === '') return
516
+ // Sessions the tools have not seen yet account under their own id; a
517
+ // subagent's account folds into its root scan at its first noteExec.
518
+ const account = usageAccountOf(scanKeyOfSession.get(sessionId) ?? sessionId)
519
+ account.requests += 1
520
+ account.inputTokens += usage.inputTokens ?? 0
521
+ account.outputTokens += usage.outputTokens ?? 0
522
+ account.totalTokens += usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
398
523
  })
524
+ const llmUsageRecord = (): Record<string, unknown> => {
525
+ const facts = activeSession !== undefined ? scanFactsOf(activeSession) : { id: '', key: '', cwd: '' }
526
+ const account = usageAccountOf(facts.key !== '' ? facts.key : AGENTLESS)
527
+ return {
528
+ requests: account.requests,
529
+ input_tokens: account.inputTokens,
530
+ output_tokens: account.outputTokens,
531
+ total_tokens: account.totalTokens,
532
+ // dsh session events carry tokens only; cost stays null (parity deviation
533
+ // recorded in the manual — strix estimates cost via LiteLLM callbacks).
534
+ cost: null,
535
+ agents: [],
536
+ }
537
+ }
399
538
 
400
539
  const ensureRunDir = async (): Promise<string> => {
401
- await mkdir(runDir, { recursive: true })
402
- return runDir
540
+ const dir = await currentRunDir()
541
+ await mkdir(dir, { recursive: true })
542
+ return dir
403
543
  }
404
544
 
405
545
  /** Resolve the coverage source: Config hook first, else the analysis package's service. */
@@ -465,6 +605,10 @@ export function apply(ctx: Context, config: Config = {}): ReportingHandle {
465
605
  const runRecord: Record<string, unknown> = {
466
606
  run_id: state.runId,
467
607
  run_name: state.runName,
608
+ session_id: (() => {
609
+ const key = scanFactsOf(activeSession).key
610
+ return key !== '' ? key : null
611
+ })(),
468
612
  start_time: state.startTime,
469
613
  end_time: state.endTime,
470
614
  status: state.status,
@@ -526,7 +670,7 @@ export function apply(ctx: Context, config: Config = {}): ReportingHandle {
526
670
 
527
671
  ctx.tools.register(defineTool({
528
672
  name: 'create_vulnerability_report',
529
- description: "File a vulnerability report — one report per fully-verified finding, with a working PoC. Severity and CVSS are computed from the 8-metric cvss_breakdown you provide. On a duplicate verdict, revise the existing report via update_vulnerability_report instead of retrying. Never for known-CVE dependency findings — use create_dependency_report.",
673
+ description: "File a vulnerability report — one report per fully-verified finding, with a working PoC. Severity and CVSS are computed from the 8-metric cvss_breakdown you provide. Validation contract (checked before filing, get these right the first time): cvss_breakdown must carry all 8 CVSS v3.1 metrics (attack_vector, attack_complexity, privileges_required, user_interaction, scope, confidentiality, integrity, availability) with their exact enum values; confidence != high requires confidence_rationale; any code_locations entry with fix_after requires fix_verification; cve must match CVE-YYYY-NNNNN and cwe must be a specific CWE-NNN child. On a duplicate verdict, revise the existing report via update_vulnerability_report instead of retrying. Never for known-CVE dependency findings — use create_dependency_report.",
530
674
  parameters: {
531
675
  title: { type: 'string', required: true, description: 'Specific finding title (e.g. "SQL Injection in /api/users login parameter").' },
532
676
  description: { type: 'string', required: true, description: 'Concise, non-technical TL;DR (1-3 sentences).' },
@@ -578,6 +722,7 @@ export function apply(ctx: Context, config: Config = {}): ReportingHandle {
578
722
  presentationMeta: (args: unknown, value: unknown) => findingPresentationMeta(args, value),
579
723
  },
580
724
  execute: (async (rawArgs: never, rawExec: never) => {
725
+ noteExec(rawExec)
581
726
  const args = rawArgs as never as CreateVulnArgs
582
727
  const exec = rawExec as never as ToolRunContextLike
583
728
  void exec
@@ -672,6 +817,7 @@ export function apply(ctx: Context, config: Config = {}): ReportingHandle {
672
817
  presentationMeta: (args: unknown, value: unknown) => findingPresentationMeta(args, value),
673
818
  },
674
819
  execute: (async (rawArgs: never, rawExec: never) => {
820
+ noteExec(rawExec)
675
821
  const args = rawArgs as never as UpdateArgs
676
822
  const exec = rawExec as never as ToolRunContextLike
677
823
  void exec
@@ -750,7 +896,7 @@ export function apply(ctx: Context, config: Config = {}): ReportingHandle {
750
896
 
751
897
  ctx.tools.register(defineTool({
752
898
  name: 'create_dependency_report',
753
- description: "File a known-CVE dependency (SCA) finding — one report per CVE x package. For vulnerable third-party package versions pinned in a lockfile/manifest/SBOM; no live PoC needed. Severity comes from the contextual_cvss_breakdown when provided, else the advisory score. Never for dynamically-proven vulnerabilities — use create_vulnerability_report.",
899
+ description: "File a known-CVE dependency (SCA) finding — one report per CVE x package. For vulnerable third-party package versions pinned in a lockfile/manifest/SBOM; no live PoC needed. Severity comes from the contextual_cvss_breakdown when provided, else the advisory score. Validation contract: besides the marked-required fields, manifest_path, reachability_evidence, contextual_cvss_breakdown AND contextual_cvss_reasoning are all required and must be non-empty (the '(required)' hints in their descriptions are enforced at execution time). Never for dynamically-proven vulnerabilities — use create_vulnerability_report.",
754
900
  parameters: {
755
901
  title: { type: 'string', required: true, description: 'e.g. "CVE-2024-1234 in lodash 4.17.20 (prototype pollution)".' },
756
902
  description: { type: 'string', required: true, description: 'What the CVE is and why the pinned version is affected.' },
@@ -790,6 +936,12 @@ export function apply(ctx: Context, config: Config = {}): ReportingHandle {
790
936
  confidence: { type: 'number' },
791
937
  reason: { type: 'string' },
792
938
  warning: { type: 'string' },
939
+ // persistCreate returns this whenever the filed report carries a
940
+ // cvss (always, for dependency findings) — omitted from the schema
941
+ // it made dsh reject the output AFTER persisting: the model saw a
942
+ // phantom failure, re-filed 3 duplicates, and retracted them
943
+ // (2026-09-22 live test, vuln-0037..0039).
944
+ cvss_score: { type: 'number' },
793
945
  },
794
946
  additionalProperties: false,
795
947
  },
@@ -801,6 +953,7 @@ export function apply(ctx: Context, config: Config = {}): ReportingHandle {
801
953
  presentationMeta: (args: unknown, value: unknown) => findingPresentationMeta(args, value),
802
954
  },
803
955
  execute: (async (rawArgs: never, _rawExec: never) => {
956
+ noteExec(_rawExec)
804
957
  const args = rawArgs as never as CreateDepArgs
805
958
  const errors: string[] = []
806
959
  const requireText = (value: string | undefined, name: string): string | undefined => {
@@ -900,6 +1053,7 @@ export function apply(ctx: Context, config: Config = {}): ReportingHandle {
900
1053
  },
901
1054
  },
902
1055
  execute: (async (rawArgs: never, rawExec: never) => {
1056
+ noteExec(rawExec)
903
1057
  const args = rawArgs as never as ListArgs
904
1058
  const exec = rawExec as never as ToolRunContextLike
905
1059
  void exec
@@ -968,6 +1122,7 @@ export function apply(ctx: Context, config: Config = {}): ReportingHandle {
968
1122
  },
969
1123
  },
970
1124
  execute: (async (rawArgs: never, rawExec: never) => {
1125
+ noteExec(rawExec)
971
1126
  const args = rawArgs as never as GetArgs
972
1127
  const exec = rawExec as never as ToolRunContextLike
973
1128
  void exec
@@ -1015,12 +1170,27 @@ export function apply(ctx: Context, config: Config = {}): ReportingHandle {
1015
1170
 
1016
1171
  const handle: ReportingHandle = {
1017
1172
  state,
1018
- runDir,
1173
+ /**
1174
+ * Sync view of the run directory: the resolved dir of the last-noted
1175
+ * session when one exists, else the unguarded default (no occupancy
1176
+ * check). Accuracy for a fresh session comes from runDirFor().
1177
+ */
1178
+ get runDir(): string {
1179
+ const facts = scanFactsOf(activeSession)
1180
+ const key = facts.key !== '' ? facts.key : ':agentless'
1181
+ const memo = resolvedRunDirs.get(key)
1182
+ if (memo !== undefined) return memo
1183
+ const base = isAbsolute(runsRoot) ? resolve(runsRoot) : resolve(facts.cwd !== '' ? facts.cwd : process.cwd(), runsRoot)
1184
+ return resolve(join(base, config.runName ?? (facts.key !== '' ? `pentest-${facts.key.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8)}` : fallbackRunName)))
1185
+ },
1186
+ runDirFor: (session: unknown) => runDirFor(session),
1019
1187
  /** Mark the scan complete and write the final report + artifacts. */
1020
- async finishScan(sections: { readonly executiveSummary: string; readonly methodology: string; readonly technicalAnalysis: string; readonly recommendations: string }, status = 'completed'): Promise<void> {
1188
+ async finishScan(sections: { readonly executiveSummary: string; readonly methodology: string; readonly technicalAnalysis: string; readonly recommendations: string }, status = 'completed', extras?: Readonly<Record<string, unknown>>): Promise<void> {
1021
1189
  state.finalScanResult = composeFinalReport(sections)
1022
1190
  state.complete(status)
1023
1191
  // strix `update_scan_final_fields` (finish tool → state.py :560-582).
1192
+ // `extras` carries reconciliation facts the caller measured at close
1193
+ // time (e.g. proxy-captured request count) into the persisted run.json.
1024
1194
  scanResults = {
1025
1195
  scan_completed: true,
1026
1196
  executive_summary: sections.executiveSummary,
@@ -1028,6 +1198,7 @@ export function apply(ctx: Context, config: Config = {}): ReportingHandle {
1028
1198
  technical_analysis: sections.technicalAnalysis,
1029
1199
  recommendations: sections.recommendations,
1030
1200
  success: status === 'completed',
1201
+ ...extras,
1031
1202
  }
1032
1203
  const dir = await ensureRunDir()
1033
1204
  await writeExecutiveReport(dir, state.finalScanResult, formatTimestamp(new Date()))
@@ -1037,7 +1208,7 @@ export function apply(ctx: Context, config: Config = {}): ReportingHandle {
1037
1208
  async writeNow(): Promise<void> {
1038
1209
  await saveArtifacts()
1039
1210
  },
1040
- readRaw: async (relative: string): Promise<string> => readFile(join(runDir, relative), 'utf8'),
1211
+ readRaw: async (relative: string): Promise<string> => readFile(join(await currentRunDir(), relative), 'utf8'),
1041
1212
  }
1042
1213
  ctx.provide('pentestReporting', handle)
1043
1214
  return handle
package/src/state.ts CHANGED
@@ -107,7 +107,8 @@ export type UpdateOutcome =
107
107
  * per-scan instance in dsh).
108
108
  */
109
109
  export class ReportState {
110
- readonly runId: string
110
+ /** Reseated per resolved run dir (see reseatRunId); NOT per-process — the state is shared for multi-round continuation. */
111
+ runId: string
111
112
  runName: string | null
112
113
  readonly startTime: string
113
114
  endTime: string | null = null
@@ -126,6 +127,17 @@ export class ReportState {
126
127
  this.startTime = formatIso(this.clock())
127
128
  }
128
129
 
130
+ /**
131
+ * Fresh run id for a newly resolved run directory. The state object stays
132
+ * process-shared so a second scan in the same host process CONTINUES the
133
+ * report ledger (multi-round retest mode) — but each run dir must carry its
134
+ * own id: before this, both rounds' run.json showed the same run_id because
135
+ * the id was minted once per process (2026-09-22).
136
+ */
137
+ reseatRunId(): void {
138
+ this.runId = `run-${Math.random().toString(16).slice(2, 10)}`
139
+ }
140
+
129
141
  /** Allocate the next sequential id (state.py :343 — length-derived). */
130
142
  private nextId(): string {
131
143
  return `vuln-${String(this.vulnerabilityReports.length + 1).padStart(4, '0')}`