Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion sdk/typescript/_bundled_plugin/references/final-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<discovery_dir>/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 `<discovery_dir>/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.

Expand Down
16 changes: 16 additions & 0 deletions sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +845 to +847

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject invalid Unicode before joining conditions

When an array entry is a JSON string containing an unpaired UTF-16 surrogate such as "\ud800", this predicate accepts it because it is a nonblank str, and the list is normalized instead of rejected. The later document-wide _require_safe_json_value call then raises outside the per-finding recovery handler, causing the entire scan completion to fail rather than discarding the malformed finding and marking coverage partial. Validate each condition with _require_safe_json_string while still inside this finding's try block.

Useful? React with 👍 / 👎.

)
):
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]},
Expand Down
59 changes: 58 additions & 1 deletion sdk/typescript/tests-ts/scan-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type Finding = Record<string, unknown> & {
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<{
Expand Down Expand Up @@ -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<FindingsDocument>(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<FindingsDocument>(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<CoverageDocument>(
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<FindingsDocument>(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");
Expand Down
Loading