@nebulacomponents/citable 1.18.1 → 1.18.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/CHANGELOG.md +30 -0
- package/dist/universal/.agents/skills/citable/SKILL.md +1 -1
- package/dist/universal/.agents/skills/citable/VERSION +1 -1
- package/dist/universal/.agents/skills/citable/commands/artifact-interchange.md +5 -0
- package/dist/universal/.agents/skills/citable/commands/remediation.md +1 -1
- package/dist/universal/.agents/skills/citable/commands/sow.md +2 -2
- package/dist/universal/.agents/skills/citable/manifest.json +11 -9
- package/dist/universal/.agents/skills/citable/schemas/cro-report.schema.json +112 -0
- package/dist/universal/.agents/skills/citable/schemas/search-report.schema.json +99 -0
- package/dist/universal/.agents/skills/citable/schemas/sow.schema.json +3 -2
- package/dist/universal/.claude/agents/citable/manifest.json +2 -2
- package/dist/universal/.claude/skills/citable/SKILL.md +1 -1
- package/dist/universal/.claude/skills/citable/VERSION +1 -1
- package/dist/universal/.claude/skills/citable/commands/artifact-interchange.md +5 -0
- package/dist/universal/.claude/skills/citable/commands/remediation.md +1 -1
- package/dist/universal/.claude/skills/citable/commands/sow.md +2 -2
- package/dist/universal/.claude/skills/citable/manifest.json +11 -9
- package/dist/universal/.claude/skills/citable/schemas/cro-report.schema.json +112 -0
- package/dist/universal/.claude/skills/citable/schemas/search-report.schema.json +99 -0
- package/dist/universal/.claude/skills/citable/schemas/sow.schema.json +3 -2
- package/dist/universal/.cursor/skills/citable/SKILL.md +1 -1
- package/dist/universal/.cursor/skills/citable/VERSION +1 -1
- package/dist/universal/.cursor/skills/citable/commands/artifact-interchange.md +5 -0
- package/dist/universal/.cursor/skills/citable/commands/remediation.md +1 -1
- package/dist/universal/.cursor/skills/citable/commands/sow.md +2 -2
- package/dist/universal/.cursor/skills/citable/manifest.json +11 -9
- package/dist/universal/.cursor/skills/citable/schemas/cro-report.schema.json +112 -0
- package/dist/universal/.cursor/skills/citable/schemas/search-report.schema.json +99 -0
- package/dist/universal/.cursor/skills/citable/schemas/sow.schema.json +3 -2
- package/dist/universal/.gemini/skills/citable/SKILL.md +1 -1
- package/dist/universal/.gemini/skills/citable/VERSION +1 -1
- package/dist/universal/.gemini/skills/citable/commands/artifact-interchange.md +5 -0
- package/dist/universal/.gemini/skills/citable/commands/remediation.md +1 -1
- package/dist/universal/.gemini/skills/citable/commands/sow.md +2 -2
- package/dist/universal/.gemini/skills/citable/manifest.json +11 -9
- package/dist/universal/.gemini/skills/citable/schemas/cro-report.schema.json +112 -0
- package/dist/universal/.gemini/skills/citable/schemas/search-report.schema.json +99 -0
- package/dist/universal/.gemini/skills/citable/schemas/sow.schema.json +3 -2
- package/dist/universal/.github/skills/citable/SKILL.md +1 -1
- package/dist/universal/.github/skills/citable/VERSION +1 -1
- package/dist/universal/.github/skills/citable/commands/artifact-interchange.md +5 -0
- package/dist/universal/.github/skills/citable/commands/remediation.md +1 -1
- package/dist/universal/.github/skills/citable/commands/sow.md +2 -2
- package/dist/universal/.github/skills/citable/manifest.json +11 -9
- package/dist/universal/.github/skills/citable/schemas/cro-report.schema.json +112 -0
- package/dist/universal/.github/skills/citable/schemas/search-report.schema.json +99 -0
- package/dist/universal/.github/skills/citable/schemas/sow.schema.json +3 -2
- package/dist/universal/.kiro/skills/citable/SKILL.md +1 -1
- package/dist/universal/.kiro/skills/citable/VERSION +1 -1
- package/dist/universal/.kiro/skills/citable/commands/artifact-interchange.md +5 -0
- package/dist/universal/.kiro/skills/citable/commands/remediation.md +1 -1
- package/dist/universal/.kiro/skills/citable/commands/sow.md +2 -2
- package/dist/universal/.kiro/skills/citable/manifest.json +11 -9
- package/dist/universal/.kiro/skills/citable/schemas/cro-report.schema.json +112 -0
- package/dist/universal/.kiro/skills/citable/schemas/search-report.schema.json +99 -0
- package/dist/universal/.kiro/skills/citable/schemas/sow.schema.json +3 -2
- package/dist/universal/.opencode/skills/citable/SKILL.md +1 -1
- package/dist/universal/.opencode/skills/citable/VERSION +1 -1
- package/dist/universal/.opencode/skills/citable/commands/artifact-interchange.md +5 -0
- package/dist/universal/.opencode/skills/citable/commands/remediation.md +1 -1
- package/dist/universal/.opencode/skills/citable/commands/sow.md +2 -2
- package/dist/universal/.opencode/skills/citable/manifest.json +11 -9
- package/dist/universal/.opencode/skills/citable/schemas/cro-report.schema.json +112 -0
- package/dist/universal/.opencode/skills/citable/schemas/search-report.schema.json +99 -0
- package/dist/universal/.opencode/skills/citable/schemas/sow.schema.json +3 -2
- package/dist/universal/.pi/agent/skills/citable/SKILL.md +1 -1
- package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
- package/dist/universal/.pi/agent/skills/citable/commands/artifact-interchange.md +5 -0
- package/dist/universal/.pi/agent/skills/citable/commands/remediation.md +1 -1
- package/dist/universal/.pi/agent/skills/citable/commands/sow.md +2 -2
- package/dist/universal/.pi/agent/skills/citable/manifest.json +11 -9
- package/dist/universal/.pi/agent/skills/citable/schemas/cro-report.schema.json +112 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/search-report.schema.json +99 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/sow.schema.json +3 -2
- package/dist/universal/.qoder/skills/citable/SKILL.md +1 -1
- package/dist/universal/.qoder/skills/citable/VERSION +1 -1
- package/dist/universal/.qoder/skills/citable/commands/artifact-interchange.md +5 -0
- package/dist/universal/.qoder/skills/citable/commands/remediation.md +1 -1
- package/dist/universal/.qoder/skills/citable/commands/sow.md +2 -2
- package/dist/universal/.qoder/skills/citable/manifest.json +11 -9
- package/dist/universal/.qoder/skills/citable/schemas/cro-report.schema.json +112 -0
- package/dist/universal/.qoder/skills/citable/schemas/search-report.schema.json +99 -0
- package/dist/universal/.qoder/skills/citable/schemas/sow.schema.json +3 -2
- package/dist/universal/.rovodev/skills/citable/SKILL.md +1 -1
- package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
- package/dist/universal/.rovodev/skills/citable/commands/artifact-interchange.md +5 -0
- package/dist/universal/.rovodev/skills/citable/commands/remediation.md +1 -1
- package/dist/universal/.rovodev/skills/citable/commands/sow.md +2 -2
- package/dist/universal/.rovodev/skills/citable/manifest.json +11 -9
- package/dist/universal/.rovodev/skills/citable/schemas/cro-report.schema.json +112 -0
- package/dist/universal/.rovodev/skills/citable/schemas/search-report.schema.json +99 -0
- package/dist/universal/.rovodev/skills/citable/schemas/sow.schema.json +3 -2
- package/dist/universal/.trae/skills/citable/SKILL.md +1 -1
- package/dist/universal/.trae/skills/citable/VERSION +1 -1
- package/dist/universal/.trae/skills/citable/commands/artifact-interchange.md +5 -0
- package/dist/universal/.trae/skills/citable/commands/remediation.md +1 -1
- package/dist/universal/.trae/skills/citable/commands/sow.md +2 -2
- package/dist/universal/.trae/skills/citable/manifest.json +11 -9
- package/dist/universal/.trae/skills/citable/schemas/cro-report.schema.json +112 -0
- package/dist/universal/.trae/skills/citable/schemas/search-report.schema.json +99 -0
- package/dist/universal/.trae/skills/citable/schemas/sow.schema.json +3 -2
- package/dist/universal/.trae-cn/skills/citable/SKILL.md +1 -1
- package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
- package/dist/universal/.trae-cn/skills/citable/commands/artifact-interchange.md +5 -0
- package/dist/universal/.trae-cn/skills/citable/commands/remediation.md +1 -1
- package/dist/universal/.trae-cn/skills/citable/commands/sow.md +2 -2
- package/dist/universal/.trae-cn/skills/citable/manifest.json +11 -9
- package/dist/universal/.trae-cn/skills/citable/schemas/cro-report.schema.json +112 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/search-report.schema.json +99 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/sow.schema.json +3 -2
- package/dist/universal/manifest.json +136 -112
- package/package.json +2 -1
- package/schemas/cro-report.schema.json +112 -0
- package/schemas/search-report.schema.json +99 -0
- package/schemas/sow.schema.json +3 -2
- package/skill/SKILL.md +1 -1
- package/skill/commands/artifact-interchange.md +5 -0
- package/skill/commands/remediation.md +1 -1
- package/skill/commands/sow.md +2 -2
- package/src/analysis/behavioral.js +5 -4
- package/src/analysis/eeat.js +10 -9
- package/src/analysis/offpage.js +19 -36
- package/src/analysis/readiness.js +8 -2
- package/src/artifacts/verifyCustomerArtifact.js +283 -0
- package/src/cli/index.js +8 -1
- package/src/reporting/executiveCroReport.js +393 -229
- package/src/reporting/executiveExport.js +6 -2
- package/src/reporting/executiveSearchReport.js +519 -271
- package/src/shared/domainUtils.js +140 -0
- package/src/shared/epistemicStatus.js +111 -0
- package/src/shared/evidenceSourceResolver.js +286 -0
- package/src/shared/htmlEscape.js +65 -0
- package/src/shared/runPackageVerifier.js +153 -0
- package/src/sow/admissibilityGate.js +20 -2
- package/src/sow/generateSow.js +125 -159
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readJson, sha256File } from './io.js';
|
|
4
|
+
import { validateAgainst } from './schemaValidator.js';
|
|
5
|
+
|
|
6
|
+
export class RunVerificationError extends Error {
|
|
7
|
+
constructor(message, details = {}) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'RunVerificationError';
|
|
10
|
+
this.code = details.code || 'RUN_VERIFICATION_FAILED';
|
|
11
|
+
this.details = details;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Verifies the cryptographic and structural integrity of an audit run evidence package.
|
|
17
|
+
*
|
|
18
|
+
* Checks:
|
|
19
|
+
* 1. Existence of run directory and manifest.json
|
|
20
|
+
* 2. Manifest compliance with run.schema.json
|
|
21
|
+
* 3. Existence and validity of checksums.json
|
|
22
|
+
* 4. Exact sha256 hash match for all sealed artifacts in checksums.json
|
|
23
|
+
* 5. Run completion status (rejecting failed or partial runs in contractual contexts)
|
|
24
|
+
* 6. Findings structural validity
|
|
25
|
+
*/
|
|
26
|
+
export function verifyRunPackage(runDir, options = {}) {
|
|
27
|
+
const {
|
|
28
|
+
requireFindings = true,
|
|
29
|
+
requireCompleted = false,
|
|
30
|
+
requireChecksums = true,
|
|
31
|
+
} = options;
|
|
32
|
+
|
|
33
|
+
if (!runDir || !fs.existsSync(runDir)) {
|
|
34
|
+
throw new RunVerificationError(`Run directory not found: ${runDir}`, { code: 'RUN_NOT_FOUND', runDir });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const manifestPath = path.join(runDir, 'manifest.json');
|
|
38
|
+
if (!fs.existsSync(manifestPath)) {
|
|
39
|
+
throw new RunVerificationError(`Run package is missing manifest.json at ${manifestPath}`, {
|
|
40
|
+
code: 'MANIFEST_MISSING',
|
|
41
|
+
runDir,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let manifest;
|
|
46
|
+
try {
|
|
47
|
+
manifest = readJson(manifestPath);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
throw new RunVerificationError(`Run manifest.json is invalid JSON: ${err.message}`, {
|
|
50
|
+
code: 'MANIFEST_INVALID',
|
|
51
|
+
runDir,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const schemaValidation = validateAgainst('run.schema.json', manifest);
|
|
56
|
+
if (!schemaValidation.valid) {
|
|
57
|
+
throw new RunVerificationError(`Run manifest violates run.schema.json: ${schemaValidation.errors.join('; ')}`, {
|
|
58
|
+
code: 'MANIFEST_SCHEMA_INVALID',
|
|
59
|
+
errors: schemaValidation.errors,
|
|
60
|
+
runDir,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (requireCompleted && manifest.status !== 'completed' && manifest.status !== 'success') {
|
|
65
|
+
throw new RunVerificationError(`Run status is "${manifest.status}"; expected completed/success`, {
|
|
66
|
+
code: 'RUN_INCOMPLETE',
|
|
67
|
+
status: manifest.status,
|
|
68
|
+
runDir,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Verify checksums.json
|
|
73
|
+
const checksumsPath = path.join(runDir, 'checksums.json');
|
|
74
|
+
let checksumsVerified = false;
|
|
75
|
+
if (fs.existsSync(checksumsPath)) {
|
|
76
|
+
let checksums;
|
|
77
|
+
try {
|
|
78
|
+
checksums = readJson(checksumsPath);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
throw new RunVerificationError(`checksums.json is invalid JSON: ${err.message}`, {
|
|
81
|
+
code: 'CHECKSUMS_INVALID',
|
|
82
|
+
runDir,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const tamperedFiles = [];
|
|
87
|
+
const missingFiles = [];
|
|
88
|
+
|
|
89
|
+
for (const [relPath, expectedHash] of Object.entries(checksums)) {
|
|
90
|
+
const artifactPath = path.join(runDir, relPath);
|
|
91
|
+
if (!fs.existsSync(artifactPath)) {
|
|
92
|
+
missingFiles.push(relPath);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const actualHash = sha256File(artifactPath);
|
|
96
|
+
if (actualHash !== expectedHash) {
|
|
97
|
+
tamperedFiles.push({ file: relPath, expected: expectedHash, actual: actualHash });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (tamperedFiles.length > 0 || missingFiles.length > 0) {
|
|
102
|
+
throw new RunVerificationError(
|
|
103
|
+
`Cryptographic verification failed for run package: ${tamperedFiles.length} tampered files, ${missingFiles.length} missing files`,
|
|
104
|
+
{
|
|
105
|
+
code: 'CHECKSUM_MISMATCH',
|
|
106
|
+
tamperedFiles,
|
|
107
|
+
missingFiles,
|
|
108
|
+
runDir,
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
checksumsVerified = true;
|
|
113
|
+
} else if (requireChecksums) {
|
|
114
|
+
throw new RunVerificationError(`Run package is missing checksums.json at ${checksumsPath}`, {
|
|
115
|
+
code: 'CHECKSUMS_MISSING',
|
|
116
|
+
runDir,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Findings verification if present or required
|
|
121
|
+
const findingsPath = path.join(runDir, 'findings.json');
|
|
122
|
+
let findings = null;
|
|
123
|
+
if (fs.existsSync(findingsPath)) {
|
|
124
|
+
try {
|
|
125
|
+
findings = readJson(findingsPath);
|
|
126
|
+
if (!Array.isArray(findings)) {
|
|
127
|
+
throw new Error('findings.json must contain an array of findings');
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
throw new RunVerificationError(`findings.json is malformed: ${err.message}`, {
|
|
131
|
+
code: 'FINDINGS_MALFORMED',
|
|
132
|
+
runDir,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
} else if (requireFindings) {
|
|
136
|
+
throw new RunVerificationError(`Run package is missing findings.json at ${findingsPath}`, {
|
|
137
|
+
code: 'FINDINGS_MISSING',
|
|
138
|
+
runDir,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
valid: true,
|
|
144
|
+
runId: manifest.run_id,
|
|
145
|
+
timestamp: manifest.timestamp,
|
|
146
|
+
status: manifest.status,
|
|
147
|
+
manifest,
|
|
148
|
+
checksumsVerified,
|
|
149
|
+
findingsPath: fs.existsSync(findingsPath) ? findingsPath : null,
|
|
150
|
+
findingsCount: Array.isArray(findings) ? findings.length : 0,
|
|
151
|
+
findings,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -104,6 +104,7 @@ export function evaluateScopeAdmissibility(findings = [], {
|
|
|
104
104
|
excludedDetectors = [],
|
|
105
105
|
roleMapping = null,
|
|
106
106
|
ownerMappingVersion = '1.0',
|
|
107
|
+
rejectTemplateDefaultOwner = false,
|
|
107
108
|
} = {}) {
|
|
108
109
|
const admitted = [];
|
|
109
110
|
const refused = [];
|
|
@@ -259,12 +260,15 @@ export function evaluateScopeAdmissibility(findings = [], {
|
|
|
259
260
|
} else if (f.owner && f.owner !== 'UNRESOLVED') {
|
|
260
261
|
resolvedOwner = f.owner;
|
|
261
262
|
ownerSource = 'finding.owner';
|
|
263
|
+
} else if (roleMapping && (roleMapping[disciplineKey] || roleMapping[detectorId])) {
|
|
264
|
+
resolvedOwner = roleMapping[disciplineKey] || roleMapping[detectorId];
|
|
265
|
+
ownerSource = 'engagement_role_mapping';
|
|
262
266
|
} else if (effectiveRoles[disciplineKey] && effectiveRoles[disciplineKey] !== 'UNRESOLVED') {
|
|
263
267
|
resolvedOwner = effectiveRoles[disciplineKey];
|
|
264
|
-
ownerSource = '
|
|
268
|
+
ownerSource = 'built_in_template_default';
|
|
265
269
|
} else if (effectiveRoles[detectorId] && effectiveRoles[detectorId] !== 'UNRESOLVED') {
|
|
266
270
|
resolvedOwner = effectiveRoles[detectorId];
|
|
267
|
-
ownerSource = '
|
|
271
|
+
ownerSource = 'built_in_template_default';
|
|
268
272
|
}
|
|
269
273
|
|
|
270
274
|
if (!resolvedOwner || resolvedOwner === 'UNRESOLVED' || resolvedOwner.toLowerCase() === 'unassigned') {
|
|
@@ -280,6 +284,19 @@ export function evaluateScopeAdmissibility(findings = [], {
|
|
|
280
284
|
continue;
|
|
281
285
|
}
|
|
282
286
|
|
|
287
|
+
if (rejectTemplateDefaultOwner && ownerSource === 'built_in_template_default') {
|
|
288
|
+
refused.push({
|
|
289
|
+
finding_id: findingId,
|
|
290
|
+
detector_id: detectorId,
|
|
291
|
+
subject,
|
|
292
|
+
gate_failed: 'ownership_clarity',
|
|
293
|
+
refusal_code: 'REFUSE-TEMPLATE-DEFAULT-OWNER',
|
|
294
|
+
refusal_rationale: `Responsible delivery owner for finding ${findingId} relies on built-in template default. Contractual SOW requires an explicit finding owner or engagement role mapping.`,
|
|
295
|
+
suggested_handling: 'Specify delivery_owner on finding or map discipline in engagement role mapping.',
|
|
296
|
+
});
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
283
300
|
// Gate 8b: Discipline Authorization Gate
|
|
284
301
|
const rawDisciplines = Array.isArray(f.discipline)
|
|
285
302
|
? f.discipline
|
|
@@ -340,6 +357,7 @@ export function evaluateScopeAdmissibility(findings = [], {
|
|
|
340
357
|
confidence,
|
|
341
358
|
recommendation: remediation,
|
|
342
359
|
acceptance_test: f.verification?.acceptance_test || `Execute closed-loop verification: citable verify remediation --finding ${detectorToRerun} --target ${subject} (Expect status: RESOLVED, zero regressions)`,
|
|
360
|
+
acceptance_basis: f.verification?.method ? 'empirical_verification_method' : (f.verification?.detector_to_rerun ? 'automated_detector_rerun' : 'deterministic_remediation_test'),
|
|
343
361
|
owner: resolvedOwner,
|
|
344
362
|
owner_source: ownerSource,
|
|
345
363
|
owner_mapping_version: ownerMappingVersion,
|
package/src/sow/generateSow.js
CHANGED
|
@@ -6,6 +6,18 @@ import { runDetectors, indexTargets } from '../detectors/framework.js';
|
|
|
6
6
|
import { evaluateScopeAdmissibility } from './admissibilityGate.js';
|
|
7
7
|
import { readJson, nowIso, sha256 } from '../shared/io.js';
|
|
8
8
|
import { validateAgainst } from '../shared/schemaValidator.js';
|
|
9
|
+
import {
|
|
10
|
+
resolveEvidenceSource,
|
|
11
|
+
getCanonicalRunTimestamp,
|
|
12
|
+
sortRunCandidatesChronologically,
|
|
13
|
+
} from '../shared/evidenceSourceResolver.js';
|
|
14
|
+
import {
|
|
15
|
+
escapeHtml,
|
|
16
|
+
escapeHtmlAttr,
|
|
17
|
+
sanitizeUrl,
|
|
18
|
+
escapeMarkdownTableCell,
|
|
19
|
+
sanitizeForMarkdown,
|
|
20
|
+
} from '../shared/htmlEscape.js';
|
|
9
21
|
|
|
10
22
|
/**
|
|
11
23
|
* Typed SOW Domain Errors
|
|
@@ -135,72 +147,7 @@ export function allocateMilestoneFees(totalUnits, count) {
|
|
|
135
147
|
return fees;
|
|
136
148
|
}
|
|
137
149
|
|
|
138
|
-
|
|
139
|
-
* Extract canonical timestamp (epoch ms) from run package
|
|
140
|
-
* Hierarchy:
|
|
141
|
-
* 1. manifest.json `timestamp` or `created_at` ISO string
|
|
142
|
-
* 2. Run ID timestamp prefix (YYYYMMDDTHHmmss)
|
|
143
|
-
* 3. findings.json mtime
|
|
144
|
-
* 4. run directory mtime
|
|
145
|
-
*/
|
|
146
|
-
export function getCanonicalRunTimestamp(runDir, runId = '') {
|
|
147
|
-
// 1. manifest.json
|
|
148
|
-
const manifestPath = path.join(runDir, 'manifest.json');
|
|
149
|
-
if (fs.existsSync(manifestPath)) {
|
|
150
|
-
try {
|
|
151
|
-
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
152
|
-
if (manifest.timestamp) {
|
|
153
|
-
const ms = Date.parse(manifest.timestamp);
|
|
154
|
-
if (!Number.isNaN(ms)) return ms;
|
|
155
|
-
}
|
|
156
|
-
if (manifest.created_at) {
|
|
157
|
-
const ms = Date.parse(manifest.created_at);
|
|
158
|
-
if (!Number.isNaN(ms)) return ms;
|
|
159
|
-
}
|
|
160
|
-
} catch {}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// 2. Run ID timestamp prefix (YYYYMMDDTHHmmss)
|
|
164
|
-
const m = String(runId).match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/);
|
|
165
|
-
if (m) {
|
|
166
|
-
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
|
|
167
|
-
const ms = Date.parse(iso);
|
|
168
|
-
if (!Number.isNaN(ms)) return ms;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// 3. findings.json mtime
|
|
172
|
-
const findPath = path.join(runDir, 'findings.json');
|
|
173
|
-
if (fs.existsSync(findPath)) {
|
|
174
|
-
try {
|
|
175
|
-
return fs.statSync(findPath).mtimeMs;
|
|
176
|
-
} catch {}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// 4. Directory mtime
|
|
180
|
-
try {
|
|
181
|
-
return fs.statSync(runDir).mtimeMs;
|
|
182
|
-
} catch {
|
|
183
|
-
return 0;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Sort run candidate directory names by chronological timestamp descending,
|
|
189
|
-
* with deterministic lexicographical tie-breaking.
|
|
190
|
-
* Invariant:
|
|
191
|
-
* latest run = max(canonical run timestamp)
|
|
192
|
-
* tie = deterministic secondary key
|
|
193
|
-
*/
|
|
194
|
-
export function sortRunCandidatesChronologically(runsDir, candidateRunIds = []) {
|
|
195
|
-
return [...candidateRunIds].sort((a, b) => {
|
|
196
|
-
const timeA = getCanonicalRunTimestamp(path.join(runsDir, a), a);
|
|
197
|
-
const timeB = getCanonicalRunTimestamp(path.join(runsDir, b), b);
|
|
198
|
-
if (timeB !== timeA) {
|
|
199
|
-
return timeB - timeA; // Descending: latest timestamp first
|
|
200
|
-
}
|
|
201
|
-
return b.localeCompare(a); // Deterministic tie-breaker
|
|
202
|
-
});
|
|
203
|
-
}
|
|
150
|
+
export { getCanonicalRunTimestamp, sortRunCandidatesChronologically };
|
|
204
151
|
|
|
205
152
|
/**
|
|
206
153
|
* Verify cross-object runtime invariants before SOW export
|
|
@@ -244,6 +191,8 @@ export function validateSowInvariants(sow) {
|
|
|
244
191
|
}
|
|
245
192
|
const wpIds = new Set(sow.work_packages.map((wp) => wp.work_package_id));
|
|
246
193
|
const delivIds = new Set(sow.deliverables.map((d) => d.deliverable_id));
|
|
194
|
+
const seenReqIds = new Set();
|
|
195
|
+
const seenFindingKeys = new Set();
|
|
247
196
|
for (const row of sow.traceability_matrix) {
|
|
248
197
|
if (!wpIds.has(row.work_package_id)) {
|
|
249
198
|
throw new SowInvariantError(`Traceability matrix references unknown work package: ${row.work_package_id}`);
|
|
@@ -254,6 +203,15 @@ export function validateSowInvariants(sow) {
|
|
|
254
203
|
if (!Array.isArray(row.evidence_ids) || row.evidence_ids.length === 0) {
|
|
255
204
|
throw new SowInvariantError(`Traceability matrix row ${row.sow_requirement_id} lacks supporting evidence_ids`);
|
|
256
205
|
}
|
|
206
|
+
if (seenReqIds.has(row.sow_requirement_id)) {
|
|
207
|
+
throw new SowInvariantError(`Duplicate sow_requirement_id in traceability matrix: ${row.sow_requirement_id}`);
|
|
208
|
+
}
|
|
209
|
+
seenReqIds.add(row.sow_requirement_id);
|
|
210
|
+
const findingKey = `${row.finding_id}:${row.target_subject || ''}`;
|
|
211
|
+
if (seenFindingKeys.has(findingKey)) {
|
|
212
|
+
throw new SowInvariantError(`Duplicate finding_id and subject in traceability matrix: ${findingKey}`);
|
|
213
|
+
}
|
|
214
|
+
seenFindingKeys.add(findingKey);
|
|
257
215
|
}
|
|
258
216
|
|
|
259
217
|
const milestones = sow.delivery_schedule?.milestones || [];
|
|
@@ -395,94 +353,100 @@ export async function generateSow(root, {
|
|
|
395
353
|
integrity_hash: null,
|
|
396
354
|
};
|
|
397
355
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
356
|
+
let resolved;
|
|
357
|
+
try {
|
|
358
|
+
resolved = await resolveEvidenceSource(root, {
|
|
359
|
+
findings: inputFindings,
|
|
360
|
+
runId,
|
|
361
|
+
target,
|
|
362
|
+
live,
|
|
363
|
+
baseUrl,
|
|
364
|
+
refDate,
|
|
365
|
+
sample,
|
|
366
|
+
demo,
|
|
367
|
+
draft,
|
|
368
|
+
scopes: ['technical', 'seo', 'aeo', 'geo', 'schema', 'entity', 'cro'],
|
|
369
|
+
});
|
|
370
|
+
} catch (err) {
|
|
371
|
+
if (err.code === 'RUN_NOT_FOUND') {
|
|
409
372
|
throw new RunNotFoundError(runId);
|
|
410
373
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
throw new FindingsMissingError(runId, findPath);
|
|
374
|
+
if (err.code === 'FINDINGS_MISSING') {
|
|
375
|
+
throw new FindingsMissingError(runId, err.details?.filePath);
|
|
414
376
|
}
|
|
415
|
-
|
|
416
|
-
findings = readJson(findPath);
|
|
417
|
-
if (!Array.isArray(findings)) throw new Error('findings.json root must be an array');
|
|
418
|
-
sourceProvenance.source_type = 'HISTORICAL_RUN';
|
|
419
|
-
sourceProvenance.source_identifier = runId;
|
|
420
|
-
sourceProvenance.integrity_hash = sha256(fs.readFileSync(findPath));
|
|
421
|
-
} catch (err) {
|
|
377
|
+
if (err.code === 'FINDINGS_INVALID') {
|
|
422
378
|
throw new FindingsInvalidError(runId, err.message);
|
|
423
379
|
}
|
|
424
|
-
|
|
425
|
-
// 3. Precedence: Live target inspection requested via --live or target
|
|
426
|
-
else if (live || target) {
|
|
427
|
-
if (!target) {
|
|
428
|
-
throw new SowError('Target is required for live inspection (e.g. --target <url|dir>)', 'TARGET_REQUIRED');
|
|
429
|
-
}
|
|
430
|
-
try {
|
|
431
|
-
const ctx = await buildContext(root, { target, baseUrl, refDate });
|
|
432
|
-
if (!ctx?.site) {
|
|
433
|
-
throw new Error(`Target ${target} did not produce a valid site context`);
|
|
434
|
-
}
|
|
435
|
-
indexTargets(ctx);
|
|
436
|
-
const detectors = selectDetectors({ scopes: ['technical', 'seo', 'aeo', 'geo', 'schema', 'entity', 'cro'] });
|
|
437
|
-
const res = runDetectors(detectors, ctx);
|
|
438
|
-
findings = res.findings || [];
|
|
439
|
-
sourceProvenance.source_type = 'LIVE_INSPECTION';
|
|
440
|
-
sourceProvenance.source_identifier = target;
|
|
441
|
-
} catch (err) {
|
|
380
|
+
if (err.code === 'LIVE_INSPECTION_FAILED') {
|
|
442
381
|
throw new LiveInspectionFailedError(target, err.message);
|
|
443
382
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
else {
|
|
448
|
-
const runsDir = path.join(root, '.citable', 'runs');
|
|
449
|
-
if (fs.existsSync(runsDir)) {
|
|
450
|
-
const candidates = fs.readdirSync(runsDir)
|
|
451
|
-
.filter((d) => !d.startsWith('.') && fs.existsSync(path.join(runsDir, d, 'findings.json')));
|
|
452
|
-
const sortedCandidates = sortRunCandidatesChronologically(runsDir, candidates);
|
|
453
|
-
|
|
454
|
-
if (sortedCandidates.length > 0) {
|
|
455
|
-
const latestRunId = sortedCandidates[0];
|
|
456
|
-
const findPath = path.join(runsDir, latestRunId, 'findings.json');
|
|
457
|
-
try {
|
|
458
|
-
findings = readJson(findPath);
|
|
459
|
-
if (Array.isArray(findings)) {
|
|
460
|
-
sourceProvenance.source_type = 'LATEST_RECORDED_RUN';
|
|
461
|
-
sourceProvenance.source_identifier = latestRunId;
|
|
462
|
-
sourceProvenance.integrity_hash = sha256(fs.readFileSync(findPath));
|
|
463
|
-
}
|
|
464
|
-
} catch {}
|
|
465
|
-
}
|
|
383
|
+
if (err.code === 'NO_FINDINGS') {
|
|
384
|
+
const srcDesc = target ? `live target "${target}"` : (runId ? `run "${runId}"` : 'local workspace');
|
|
385
|
+
throw new NoFindingsError(srcDesc);
|
|
466
386
|
}
|
|
387
|
+
throw err;
|
|
467
388
|
}
|
|
468
389
|
|
|
469
|
-
|
|
390
|
+
findings = resolved.findings;
|
|
391
|
+
sourceProvenance = {
|
|
392
|
+
source_type: resolved.source_type,
|
|
393
|
+
source_identifier: resolved.source_identifier,
|
|
394
|
+
findings_count: resolved.findings_count,
|
|
395
|
+
integrity_hash: resolved.integrity_hash,
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// If no findings were discovered:
|
|
470
399
|
if (!Array.isArray(findings) || findings.length === 0) {
|
|
471
400
|
if (isSample) {
|
|
472
401
|
findings = getSampleBaselineFindings();
|
|
473
402
|
sourceProvenance.source_type = 'SAMPLE_BASELINE';
|
|
474
403
|
sourceProvenance.source_identifier = 'citable://samples/enterprise-baseline';
|
|
404
|
+
sourceProvenance.findings_count = findings.length;
|
|
475
405
|
} else {
|
|
476
406
|
const srcDesc = target ? `live target "${target}"` : (runId ? `run "${runId}"` : 'local workspace');
|
|
477
407
|
throw new NoFindingsError(srcDesc);
|
|
478
408
|
}
|
|
479
409
|
}
|
|
480
410
|
|
|
481
|
-
|
|
411
|
+
if (generationMode === 'CONTRACTUAL') {
|
|
412
|
+
if (!client || typeof client !== 'string' || client.trim() === '') {
|
|
413
|
+
throw new SowError('Contractual SOW requires explicit client name', 'INVALID_COMMERCIAL_TERMS');
|
|
414
|
+
}
|
|
415
|
+
if (budget === null && budgetMinor === null) {
|
|
416
|
+
throw new SowError('Contractual SOW requires explicit budget or budgetMinor', 'INVALID_COMMERCIAL_TERMS');
|
|
417
|
+
}
|
|
418
|
+
if (!termDays || termDays <= 0 || !Number.isFinite(termDays)) {
|
|
419
|
+
throw new SowError('Contractual SOW requires positive finite termDays', 'INVALID_COMMERCIAL_TERMS');
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
let effectiveScopeProps = inScopeProperties.length > 0 ? [...inScopeProperties] : [];
|
|
424
|
+
if (effectiveScopeProps.length === 0) {
|
|
425
|
+
if (baseUrl) {
|
|
426
|
+
effectiveScopeProps.push(baseUrl);
|
|
427
|
+
} else if (target && typeof target === 'string' && target.startsWith('http')) {
|
|
428
|
+
effectiveScopeProps.push(target);
|
|
429
|
+
} else if (isSample) {
|
|
430
|
+
effectiveScopeProps.push('https://example.test');
|
|
431
|
+
} else {
|
|
432
|
+
// Derive from finding subjects
|
|
433
|
+
for (const f of findings) {
|
|
434
|
+
const s = f.subject?.identifier || f.subject?.url || (typeof f.subject === 'string' ? f.subject : null);
|
|
435
|
+
if (s) {
|
|
436
|
+
try {
|
|
437
|
+
const u = new URL(s.includes('://') ? s : `https://${s}`);
|
|
438
|
+
if (!effectiveScopeProps.includes(u.origin)) {
|
|
439
|
+
effectiveScopeProps.push(u.origin);
|
|
440
|
+
}
|
|
441
|
+
} catch {}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
482
446
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
447
|
+
if (generationMode === 'CONTRACTUAL' && effectiveScopeProps.length === 0) {
|
|
448
|
+
throw new SowError('Contractual SOW requires explicit inScopeProperties or derivable target properties', 'SCOPE_REQUIRED');
|
|
449
|
+
}
|
|
486
450
|
|
|
487
451
|
// -------------------------------------------------------------
|
|
488
452
|
// THE ADMISSIBILITY GATE (Filter findings into contractual scope)
|
|
@@ -495,6 +459,7 @@ export async function generateSow(root, {
|
|
|
495
459
|
allowExperimental,
|
|
496
460
|
excludedDetectors,
|
|
497
461
|
roleMapping,
|
|
462
|
+
rejectTemplateDefaultOwner: generationMode === 'CONTRACTUAL',
|
|
498
463
|
});
|
|
499
464
|
|
|
500
465
|
const admitted = gateResult.admitted_requirements;
|
|
@@ -536,6 +501,7 @@ export async function generateSow(root, {
|
|
|
536
501
|
severity: item.severity,
|
|
537
502
|
target_subject: item.subject,
|
|
538
503
|
acceptance_criteria: item.acceptance_test,
|
|
504
|
+
acceptance_basis: item.acceptance_basis || 'automated_detector_rerun',
|
|
539
505
|
}));
|
|
540
506
|
|
|
541
507
|
// Deliverables definitions
|
|
@@ -797,7 +763,7 @@ export async function generateSow(root, {
|
|
|
797
763
|
// Machine-readable Provenance Envelope
|
|
798
764
|
generation_provenance: {
|
|
799
765
|
generated_at: generatedAt,
|
|
800
|
-
generator_version: '1.18.
|
|
766
|
+
generator_version: '1.18.2',
|
|
801
767
|
generation_mode: generationMode,
|
|
802
768
|
source_type: sourceProvenance.source_type || 'UNKNOWN',
|
|
803
769
|
source_identifier: sourceProvenance.source_identifier || null,
|
|
@@ -997,7 +963,7 @@ export function renderSowHtml(sow) {
|
|
|
997
963
|
<html lang="en">
|
|
998
964
|
<head>
|
|
999
965
|
<meta charset="UTF-8">
|
|
1000
|
-
<title>${sow.title} - ${sow.sow_id}</title>
|
|
966
|
+
<title>${escapeHtml(sow.title)} - ${escapeHtml(sow.sow_id)}</title>
|
|
1001
967
|
<style>
|
|
1002
968
|
:root { --bg: #0d1117; --card: #161b22; --border: #30363d; --text: #c9d1d9; --accent: #58a6ff; --danger: #f85149; --warning: #d29922; --success: #3fb950; }
|
|
1003
969
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; margin: 0; padding: 40px 20px; }
|
|
@@ -1025,14 +991,14 @@ export function renderSowHtml(sow) {
|
|
|
1025
991
|
<div class="container">
|
|
1026
992
|
<header>
|
|
1027
993
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
|
1028
|
-
<h1>${sow.title}</h1>
|
|
994
|
+
<h1>${escapeHtml(sow.title)}</h1>
|
|
1029
995
|
<div>
|
|
1030
|
-
<span class="sow-badge">${sow.generation_mode}</span>
|
|
1031
|
-
<span class="sow-badge" style="background:#1f6feb; color:#fff; margin-left:8px;">${sow.sow_id}</span>
|
|
996
|
+
<span class="sow-badge">${escapeHtml(sow.generation_mode)}</span>
|
|
997
|
+
<span class="sow-badge" style="background:#1f6feb; color:#fff; margin-left:8px;">${escapeHtml(sow.sow_id)}</span>
|
|
1032
998
|
</div>
|
|
1033
999
|
</div>
|
|
1034
1000
|
<div style="color:#8b949e; font-size:14px; margin-top:8px;">
|
|
1035
|
-
Client: <strong>${sow.client.name}</strong> | Supplier: <strong>${sow.supplier.name}</strong> | Term: <strong>${sow.term_days} Days</strong> | Effective: <strong>${sow.effective_date}</strong> | Source: <strong>${sow.generation_provenance.source_type}</strong>
|
|
1001
|
+
Client: <strong>${escapeHtml(sow.client.name)}</strong> | Supplier: <strong>${escapeHtml(sow.supplier.name)}</strong> | Term: <strong>${escapeHtml(String(sow.term_days))} Days</strong> | Effective: <strong>${escapeHtml(sow.effective_date)}</strong> | Source: <strong>${escapeHtml(sow.generation_provenance.source_type)}</strong>
|
|
1036
1002
|
</div>
|
|
1037
1003
|
</header>
|
|
1038
1004
|
|
|
@@ -1047,10 +1013,10 @@ export function renderSowHtml(sow) {
|
|
|
1047
1013
|
|
|
1048
1014
|
<h2 class="section-title">1. Executive Scope Statement</h2>
|
|
1049
1015
|
<div class="card">
|
|
1050
|
-
<p><strong>Objective:</strong> ${sow.executive_scope.business_objective}</p>
|
|
1051
|
-
<p><strong>In-Scope Properties:</strong> <code>${sow.executive_scope.in_scope_properties.join(', ')}</code></p>
|
|
1052
|
-
<p><strong>In-Scope Systems:</strong> ${sow.executive_scope.in_scope_systems.join('; ')}</p>
|
|
1053
|
-
<p><strong>Boundaries:</strong> ${sow.executive_scope.organizational_boundaries}</p>
|
|
1016
|
+
<p><strong>Objective:</strong> ${escapeHtml(sow.executive_scope.business_objective)}</p>
|
|
1017
|
+
<p><strong>In-Scope Properties:</strong> <code>${escapeHtml(sow.executive_scope.in_scope_properties.join(', '))}</code></p>
|
|
1018
|
+
<p><strong>In-Scope Systems:</strong> ${escapeHtml(sow.executive_scope.in_scope_systems.join('; '))}</p>
|
|
1019
|
+
<p><strong>Boundaries:</strong> ${escapeHtml(sow.executive_scope.organizational_boundaries)}</p>
|
|
1054
1020
|
</div>
|
|
1055
1021
|
|
|
1056
1022
|
<h2 class="section-title">2. Scope Admissibility Gate & Excluded Findings Log</h2>
|
|
@@ -1061,11 +1027,11 @@ export function renderSowHtml(sow) {
|
|
|
1061
1027
|
<tbody>
|
|
1062
1028
|
${sow.admissibility_gate.refusal_log.map((r) => `
|
|
1063
1029
|
<tr>
|
|
1064
|
-
<td><span class="code-ref">${r.finding_id}</span></td>
|
|
1065
|
-
<td>${r.subject}</td>
|
|
1066
|
-
<td><code>${r.gate_failed}</code></td>
|
|
1067
|
-
<td><span class="badge badge-refuse">${r.refusal_code}</span></td>
|
|
1068
|
-
<td>${r.refusal_rationale}</td>
|
|
1030
|
+
<td><span class="code-ref">${escapeHtml(r.finding_id)}</span></td>
|
|
1031
|
+
<td>${escapeHtml(r.subject)}</td>
|
|
1032
|
+
<td><code>${escapeHtml(r.gate_failed)}</code></td>
|
|
1033
|
+
<td><span class="badge badge-refuse">${escapeHtml(r.refusal_code)}</span></td>
|
|
1034
|
+
<td>${escapeHtml(r.refusal_rationale)}</td>
|
|
1069
1035
|
</tr>
|
|
1070
1036
|
`).join('')}
|
|
1071
1037
|
</tbody>
|
|
@@ -1079,10 +1045,10 @@ export function renderSowHtml(sow) {
|
|
|
1079
1045
|
<tbody>
|
|
1080
1046
|
${sow.delivery_schedule.milestones.map((m) => `
|
|
1081
1047
|
<tr>
|
|
1082
|
-
<td><strong>${m.milestone_id}</strong></td>
|
|
1083
|
-
<td>${m.name}</td>
|
|
1084
|
-
<td><span class="code-ref">${m.work_package_id}</span></td>
|
|
1085
|
-
<td>${m.billing_trigger}</td>
|
|
1048
|
+
<td><strong>${escapeHtml(m.milestone_id)}</strong></td>
|
|
1049
|
+
<td>${escapeHtml(m.name)}</td>
|
|
1050
|
+
<td><span class="code-ref">${escapeHtml(m.work_package_id)}</span></td>
|
|
1051
|
+
<td>${escapeHtml(m.billing_trigger)}</td>
|
|
1086
1052
|
<td style="font-weight:bold; color:var(--success);">$${m.fee_usd.toLocaleString('en-US', { minimumFractionDigits: (m.fee_minor % 100 === 0) ? 0 : 2, maximumFractionDigits: 2 })} USD</td>
|
|
1087
1053
|
</tr>
|
|
1088
1054
|
`).join('')}
|
|
@@ -1098,13 +1064,13 @@ export function renderSowHtml(sow) {
|
|
|
1098
1064
|
<tbody>
|
|
1099
1065
|
${sow.traceability_matrix.map((t) => `
|
|
1100
1066
|
<tr>
|
|
1101
|
-
<td><span class="code-ref">${t.finding_id}</span></td>
|
|
1102
|
-
<td>${t.recommendation}</td>
|
|
1103
|
-
<td><strong>${t.sow_requirement_id}</strong></td>
|
|
1104
|
-
<td>${t.deliverable_id}</td>
|
|
1105
|
-
<td><code>${t.acceptance_test_id}</code></td>
|
|
1106
|
-
<td>${t.owner}</td>
|
|
1107
|
-
<td><span class="code-ref">${t.evidence_ids?.join(', ') || t.evidence_id}</span></td>
|
|
1067
|
+
<td><span class="code-ref">${escapeHtml(t.finding_id)}</span></td>
|
|
1068
|
+
<td>${escapeHtml(t.recommendation)}</td>
|
|
1069
|
+
<td><strong>${escapeHtml(t.sow_requirement_id)}</strong></td>
|
|
1070
|
+
<td>${escapeHtml(t.deliverable_id)}</td>
|
|
1071
|
+
<td><code>${escapeHtml(t.acceptance_test_id)}</code></td>
|
|
1072
|
+
<td>${escapeHtml(t.owner)}</td>
|
|
1073
|
+
<td><span class="code-ref">${escapeHtml(t.evidence_ids?.join(', ') || t.evidence_id)}</span></td>
|
|
1108
1074
|
</tr>
|
|
1109
1075
|
`).join('')}
|
|
1110
1076
|
</tbody>
|
|
@@ -1113,7 +1079,7 @@ export function renderSowHtml(sow) {
|
|
|
1113
1079
|
<h2 class="section-title">Signatures & Contractual Authorization</h2>
|
|
1114
1080
|
<table class="sig-table">
|
|
1115
1081
|
<thead>
|
|
1116
|
-
<tr><th>For Customer: ${sow.client.name}</th><th>For Supplier: ${sow.supplier.name}</th></tr>
|
|
1082
|
+
<tr><th>For Customer: ${escapeHtml(sow.client.name)}</th><th>For Supplier: ${escapeHtml(sow.supplier.name)}</th></tr>
|
|
1117
1083
|
</thead>
|
|
1118
1084
|
<tbody>
|
|
1119
1085
|
<tr>
|