Skip to content
Merged
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
55 changes: 54 additions & 1 deletion packages/deepsec/src/__tests__/osv-enrich.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { buildContext, findManifest, findPom, parseOsv } from "../setup/osv-enrich.js";
import {
buildContext,
findManifest,
findManifests,
findPom,
parseOsv,
} from "../setup/osv-enrich.js";

// osv-scanner's JSON groups packages under results[].packages[], each result
// tagged with a source lockfile path, each package carrying vulnerabilities[]
Expand Down Expand Up @@ -90,6 +96,36 @@ describe("parseOsv", () => {
});
});

describe("parseOsv field sanitization (prompt-injection defense)", () => {
it("collapses newlines/markdown in attacker-controlled name and version", () => {
const raw = JSON.stringify({
results: [
{
source: { path: "requirements.txt" },
packages: [
{
package: {
name: "evil\n## Injected heading\n- bullet",
version: "1.0\n\nmore",
ecosystem: "PyPI",
},
vulnerabilities: [{ id: "CVE-1", summary: "x" }],
},
],
},
],
});
const hits = parseOsv(raw);
expect(hits).toHaveLength(1);
expect(hits[0].name).not.toContain("\n");
expect(hits[0].version).not.toContain("\n");
// and the rendered prompt block carries no injected newlines from those fields
const ctx = buildContext(hits);
expect(ctx).toContain("Injected heading"); // present, but inline — not a real heading
expect(ctx).not.toMatch(/\n##? Injected heading/);
});
});

describe("findManifest", () => {
it("finds a nested pyproject.toml but skips vendored and hidden trees", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-manifest-"));
Expand Down Expand Up @@ -117,6 +153,23 @@ describe("findManifest", () => {
});
});

describe("findManifests (monorepo)", () => {
it("returns every pyproject.toml, not just the first", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-multi-"));
try {
fs.mkdirSync(path.join(root, "pkg-a"), { recursive: true });
fs.mkdirSync(path.join(root, "pkg-b"), { recursive: true });
fs.writeFileSync(path.join(root, "pkg-a", "pyproject.toml"), "");
fs.writeFileSync(path.join(root, "pkg-b", "pyproject.toml"), "");
const found = findManifests(root);
expect(found).toHaveLength(2);
expect(findManifest(root)).toBe(found[0]); // single-use helper returns the first
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});

describe("findPom", () => {
it("finds a nested pom.xml but skips vendored trees", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-pom-"));
Expand Down
15 changes: 12 additions & 3 deletions packages/deepsec/src/setup/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,9 @@ export async function runSetupWorkflow(
agentType,
model,
thinkingLevel,
rubricVersion: 2,
// Bumped to 3 for the osv dependency-CVE enrichment: invalidates pre-upgrade
// info checkpoints so existing projects re-run and gain dependency findings.
rubricVersion: 3,
});
if (
!analysis ||
Expand All @@ -532,7 +534,10 @@ export async function runSetupWorkflow(
}),
);
writeRepositoryAnalysis(projectDataDir, analysis);
if (preserveInfo) atomicWriteFileSync(infoPath, preserveInfo);
// Preserve a prior complete (possibly hand-edited) INFO.md — but NOT when the
// osv step injected dependency CVEs, or restoring the old file would discard
// the advisory-reachability analysis that matcher generation later reads.
if (preserveInfo && !osvEnrichment.context) atomicWriteFileSync(infoPath, preserveInfo);
state.infoDigest = digest(fs.readFileSync(infoPath, "utf8"));
state.inventoryDigest = digest(analysis.surfaces);
writeSetupState(state);
Expand Down Expand Up @@ -640,10 +645,14 @@ export async function runSetupWorkflow(
const dependencyCveCount = readOsvFindingCount(projectDataDir);
const hasDepCves = dependencyCveCount > 0;

// Run the dependency-CVE matcher pass once (on first-ever matcher generation),
// not on every resume — otherwise each resumed setup re-spends agent/scan work
// and accumulates duplicate matchers under new slugs.
const depCveFirstPass = hasDepCves && state.matcherAttempts.length === 0;
const maxAttempts = 2;
for (
let attempt = 0;
attempt < maxAttempts && (!coverage.passed || (hasDepCves && attempt === 0));
attempt < maxAttempts && (!coverage.passed || (depCveFirstPass && attempt === 0));
attempt++
) {
reporter.emit({
Expand Down
2 changes: 1 addition & 1 deletion packages/deepsec/src/setup/generated-matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export async function proposeGeneratedMatchers(
hasDepCves && options.threatModel?.trim()
? `

This project has known-vulnerable dependencies. In addition to the surface gaps above, propose matchers that flag the *reachable* dependency advisories described in the threat model below. Anchor each to the specific vulnerable call site or condition (for example a form-parsing call made with no size/field limit), so those spots become scan candidates. Skip advisories the threat model marks not-reachable. Such a matcher may legitimately have an empty closesSurfaceIds.
This project has known-vulnerable dependencies. In addition to the surface gaps above, propose matchers that flag the *reachable* dependency advisories described in the threat model below. Anchor each to the specific vulnerable call site or condition (for example a form-parsing call made with no size/field limit), so those spots become scan candidates. Skip advisories the threat model marks not-reachable. Set closesSurfaceIds to the surface(s) from the inventory where the vulnerable call occurs (the matcher schema requires at least one); use the closest ingress surface that reaches the call.

Threat model (INFO.md):
${options.threatModel.trim()}`
Expand Down
Loading