@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/lib/index.d.ts +10 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +182 -31
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +203 -32
- package/src/state.ts +13 -1
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.
|
|
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
|
-
|
|
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
|
|
368
|
-
const
|
|
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
|
-
//
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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')}`
|