Skip to content

Commit 908bdb7

Browse files
authored
fix: salvage malformed optional remediation fields during finding recovery (#159)
A finding whose optional remediationTests or preventiveControls field arrived in the wrong shape was discarded entirely at finalization, even when every required field validated. Recover these fields the same way malformed writeups are recovered: drop the field, keep the finding, and record a warning. Also document the array-of-strings shape for both fields in the finding detail reference and the bundled completed-scan example, since no reference or example previously showed it. Fixes #106
1 parent 01532a5 commit 908bdb7

5 files changed

Lines changed: 105 additions & 3 deletions

File tree

sdk/typescript/_bundled_plugin/examples/completed-scan/findings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@
4242
"remediation": "Normalize destinations and reject entries that escape the extraction root.",
4343
"validation": null,
4444
"attackPath": null,
45+
"remediationTests": [
46+
"Assert that extracting an archive entry named `../escape.txt` fails without writing outside the extraction root."
47+
],
48+
"preventiveControls": [
49+
"Route all archive extraction through one helper that normalizes and validates destination paths."
50+
],
4551
"provenance": {
4652
"source": "local_plugin"
4753
},

sdk/typescript/_bundled_plugin/examples/completed-scan/scan-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"artifacts": [
3131
{
3232
"path": "findings.json",
33-
"sha256": "db5ce8533c1b6cd9131f9e12e8bb705f63474caa2a3f7dd21207666b3a8da777",
33+
"sha256": "a6dc4521d6478828224fbafc33585401a47bfeb0e56e07b5d2886e8a29937f2f",
3434
"mediaType": "application/json"
3535
},
3636
{

sdk/typescript/_bundled_plugin/references/finding-detail-fields.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ The finding detail view is a decision-focused projection of the canonical findin
2323
- dataflow source, meaningful transformations, dangerous sink, and concrete outcome;
2424
- realistic attacker, entry point, access requirements, preconditions, and attacker outcome;
2525
- severity rationale plus the specific evidence that would raise or lower the rating;
26-
- the minimal remediation invariant, regression tests, and preventive controls.
26+
- the minimal remediation invariant (`remediation`, a single string), plus `remediationTests` and `preventiveControls`, each an array of short strings with one regression test or preventive control per entry.
2727

2828
Keep background exposition, alternate exploit research, full PoC instructions, representative command output, and long source walkthroughs in the detailed write-up. Do not copy them into canonical fields merely to make the workspace report longer. The workspace should stay self-contained enough to support triage while avoiding duplicated or speculative prose.
2929

@@ -155,7 +155,15 @@ The following shape shows how to encode the `environment/add` reserved-environme
155155
"limitations": [
156156
"This overwrite does not directly execute code on the victim host."
157157
]
158-
}
158+
},
159+
"remediation": "Reuse the startup reserved-ID check inside `EnvironmentManager::upsert_environment()` so the runtime mutation path rejects the reserved `local` identifier.",
160+
"remediationTests": [
161+
"Assert that `environment/add` with `environmentId: \"local\"` returns a protocol error.",
162+
"Assert that `default_environment()` still resolves the manager-owned local runtime after a rejected upsert."
163+
],
164+
"preventiveControls": [
165+
"Centralize reserved-identifier validation so every environment mutation path shares one guard."
166+
]
159167
}
160168
```
161169

sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,12 @@ def _recover_unsealed_findings(
796796
writeup_schema = _require_dict(
797797
finding_properties, "writeup", "findings.schema.properties.findings.items.properties"
798798
)
799+
auxiliary_schemas = {
800+
name: _require_dict(
801+
finding_properties, name, "findings.schema.properties.findings.items.properties"
802+
)
803+
for name in ("remediationTests", "preventiveControls")
804+
}
799805
scan = _require_dict(manifest, "scan", "manifest")
800806
scan_id = _require_str(scan, "id", "manifest.scan")
801807
if findings.get("scanId") != scan_id:
@@ -854,6 +860,18 @@ def _recover_unsealed_findings(
854860
except ContractError as exc:
855861
finding.pop("writeup")
856862
warnings.append(f"Skipped malformed writeup for finding {index + 1}: {exc}.")
863+
for auxiliary, auxiliary_schema in auxiliary_schemas.items():
864+
if auxiliary not in finding:
865+
continue
866+
try:
867+
_validate_schema_node(
868+
finding[auxiliary], auxiliary_schema, f"{context}.{auxiliary}"
869+
)
870+
except ContractError as exc:
871+
finding.pop(auxiliary)
872+
warnings.append(
873+
f"Skipped malformed {auxiliary} for finding {index + 1}: {exc}."
874+
)
857875
_validate_schema_node(finding, finding_schema, context)
858876
except ContractError as exc:
859877
warning = f"Skipped malformed finding {index + 1}: {exc}."

sdk/typescript/tests-ts/scan-recovery.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ type Finding = Record<string, unknown> & {
3030
explanation: string;
3131
}>;
3232
writeup?: unknown;
33+
remediationTests?: unknown;
34+
preventiveControls?: unknown;
3335
};
3436

3537
type FindingsDocument = {
@@ -679,6 +681,74 @@ describe("malformed scan artifact recovery", () => {
679681
}
680682
});
681683

684+
test("keeps findings while removing malformed remediation guidance", async () => {
685+
const fixture = await startDraftScan();
686+
const path = join(fixture.scanDir, "findings.json");
687+
const document = await readJson<FindingsDocument>(path);
688+
const valid = document.findings[0]!;
689+
690+
const cases: Array<
691+
[string, "remediationTests" | "preventiveControls", unknown]
692+
> = [
693+
[
694+
"valid-remediation-tests",
695+
"remediationTests",
696+
["Add a regression test."],
697+
],
698+
["prose-remediation-tests", "remediationTests", "Add a regression test."],
699+
[
700+
"object-remediation-tests",
701+
"remediationTests",
702+
[{ description: "Add a regression test." }],
703+
],
704+
[
705+
"prose-preventive-controls",
706+
"preventiveControls",
707+
"Centralize validation.",
708+
],
709+
];
710+
for (const [anchor, field, value] of cases) {
711+
const finding = structuredClone(valid);
712+
finding.identity.anchor = anchor;
713+
finding[field] = value;
714+
document.findings.push(finding);
715+
}
716+
await writeJson(path, document);
717+
718+
const completed = await completeScan(fixture);
719+
720+
expect(completed.progress.status).toBe("complete");
721+
expect(completed.findingCount).toBe(5);
722+
expect(completed.warnings).toHaveLength(3);
723+
expect(
724+
completed.warnings.filter((warning) =>
725+
warning.startsWith("Skipped malformed remediationTests for finding"),
726+
),
727+
).toHaveLength(2);
728+
expect(
729+
completed.warnings.filter((warning) =>
730+
warning.startsWith("Skipped malformed preventiveControls for finding"),
731+
),
732+
).toHaveLength(1);
733+
const recovered = (await readJson<FindingsDocument>(path)).findings;
734+
expect(
735+
recovered.find(
736+
(finding) => finding?.identity.anchor === "valid-remediation-tests",
737+
)?.remediationTests,
738+
).toEqual(["Add a regression test."]);
739+
for (const [anchor, field] of [
740+
["prose-remediation-tests", "remediationTests"],
741+
["object-remediation-tests", "remediationTests"],
742+
["prose-preventive-controls", "preventiveControls"],
743+
] as const) {
744+
const finding = recovered.find(
745+
(candidate) => candidate?.identity.anchor === anchor,
746+
);
747+
expect(finding).toBeDefined();
748+
expect(finding).not.toHaveProperty(field);
749+
}
750+
});
751+
682752
test("keeps verified coverage receipts and downgrades invalid coverage", async () => {
683753
const fixture = await startDraftScan();
684754
const path = join(fixture.scanDir, "coverage.json");

0 commit comments

Comments
 (0)