diff --git a/sdk/typescript/_bundled_plugin/references/final-report.md b/sdk/typescript/_bundled_plugin/references/final-report.md index 9e5f555..d60e9ef 100644 --- a/sdk/typescript/_bundled_plugin/references/final-report.md +++ b/sdk/typescript/_bundled_plugin/references/final-report.md @@ -53,7 +53,7 @@ Set the finding category and CWE from the primary broken control. Do not add sec Examples that should normally become separate final findings include SQL API modes such as `execute`, `executemany`, and `executescript`; deserializer variants such as `pickle.load`, `pickle.loads`, `yaml.load`, and `yaml.load_all`; distinct path/file helper calls; SSRF modes with different destination controls; and missing-auth protected actions such as create, delete, reset, admin, and job-trigger endpoints. -For a standard repository or scoped-path scan, assemble the canonical JSON from the enriched `/candidate_ledger.jsonl`. Map each nested `validation` record into the finding's validation fields, map its confidence and rationale into top-level `confidence.level` and `confidence.rationale`, and map each nested `attack_path` record into dataflow, reachability, severity, and change conditions. +For a standard repository or scoped-path scan, assemble the canonical JSON from the enriched `/candidate_ledger.jsonl`. Map each nested `validation` record into the finding's validation fields, map its confidence and rationale into top-level `confidence.level` and `confidence.rationale`, and map each nested `attack_path` record into dataflow, reachability, severity, and change conditions. Canonical `severity.changeConditions` must be one non-empty string; when `attack_path.change_conditions` contains multiple strings, join them into one prose string before writing `findings.json`. Apply row outcomes in this order: validation disposition `reportable` plus attack-path decision `reportable` becomes a finding with its distinct instance and all relevant entrypoint, root-control, sink, and supporting locations; otherwise, a `deferred` result from either phase becomes `needs_follow_up` coverage and a `coverage.deferred` entry using the recorded uncertainty or proof gap; otherwise, validation disposition `not_applicable` becomes `not_applicable` coverage; otherwise, validation disposition `suppressed` or attack-path decision `ignore` becomes `rejected` coverage. A missing required phase record leaves the candidate unresolved and prevents complete coverage. Do not require phase receipts, per-candidate narratives, or another reconciliation pass. diff --git a/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py b/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py index 3119005..d7f0f13 100644 --- a/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py +++ b/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py @@ -836,6 +836,22 @@ def _recover_unsealed_findings( parent[field] = normalized normalized_fields.append(label) + severity = finding.get("severity") + if isinstance(severity, dict): + change_conditions = severity.get("changeConditions") + if ( + isinstance(change_conditions, list) + and change_conditions + and all( + isinstance(condition, str) and condition.strip() + for condition in change_conditions + ) + ): + severity["changeConditions"] = " ".join( + condition.strip() for condition in change_conditions + ) + normalized_fields.append("severity change conditions") + _populate_unsealed_finding_identities( manifest, {"scanId": scan_id, "findings": [finding]}, diff --git a/sdk/typescript/tests-ts/scan-recovery.test.ts b/sdk/typescript/tests-ts/scan-recovery.test.ts index 2da5fb9..77c86df 100644 --- a/sdk/typescript/tests-ts/scan-recovery.test.ts +++ b/sdk/typescript/tests-ts/scan-recovery.test.ts @@ -18,7 +18,7 @@ type Finding = Record & { ruleId: string; identity: { anchor: string; instance?: string }; summary: string; - severity: { level: string }; + severity: { level: string; changeConditions?: unknown }; confidence: { level: string }; locations: Array<{ path: string }>; codeEvidence?: Array<{ @@ -424,6 +424,63 @@ describe("malformed scan artifact recovery", () => { expect((saved["scan"] as ScanSummary).warnings).toEqual([warning]); }); + test("normalizes severity change-condition lists without losing findings", async () => { + const fixture = await startDraftScan(); + const path = join(fixture.scanDir, "findings.json"); + const document = await readJson(path); + document.findings[0]!.severity.changeConditions = [ + "Raise if the vulnerable path becomes internet-reachable.", + "Lower if the input is constrained before parsing.", + ]; + await writeJson(path, document); + + const completed = await completeScan(fixture); + + expect(completed.progress.status).toBe("complete"); + expect(completed.findingCount).toBe(1); + expect(completed.warnings).toEqual([ + "Recovered finding 1: normalized severity change conditions.", + ]); + const recovered = (await readJson(path)).findings[0]!; + expect(recovered.severity.changeConditions).toBe( + "Raise if the vulnerable path becomes internet-reachable. " + + "Lower if the input is constrained before parsing.", + ); + const coverage = await readJson( + join(fixture.scanDir, "coverage.json"), + ); + expect(coverage.completeness).toBe("complete"); + }); + + test("rejects severity change-condition lists with malformed entries", async () => { + const fixture = await startDraftScan(); + const path = join(fixture.scanDir, "findings.json"); + const document = await readJson(path); + const valid = document.findings[0]!; + + for (const [anchor, conditions] of [ + ["empty-severity-conditions", []], + ["blank-severity-condition", [" "]], + ["mixed-severity-conditions", ["Valid condition.", 1]], + ] as const) { + const finding = structuredClone(valid); + finding.identity.anchor = anchor; + finding.severity.changeConditions = conditions; + document.findings.push(finding); + } + await writeJson(path, document); + + const completed = await completeScan(fixture); + + expect(completed.findingCount).toBe(1); + expect(completed.warnings).toHaveLength(3); + expect( + completed.warnings.every((warning) => + warning.includes("severity.changeConditions"), + ), + ).toBe(true); + }); + test("keeps valid findings and skips malformed or duplicate findings", async () => { const fixture = await startDraftScan(); const path = join(fixture.scanDir, "findings.json");