Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f988813
Fix false secret matches in source maps
sandstream Oct 30, 2026
b04dee9
Scan source maps incrementally
sandstream Oct 30, 2026
fc536ff
Handle unusual source map entries safely
sandstream Oct 30, 2026
837ce3f
Bound source map scan work
sandstream Oct 30, 2026
095e348
Report incomplete scans without hiding findings
sandstream Oct 30, 2026
5dc69c6
Handle invalid and escaped source map fields
sandstream Oct 30, 2026
a0b47c6
Reject invalid source map formats
sandstream Oct 30, 2026
5e582c6
Scan invalid source content without false matches
sandstream Oct 30, 2026
b573c9a
Retain generic credentials in invalid source maps
sandstream Oct 30, 2026
af19b51
Decode metadata and incomplete source maps
sandstream Oct 30, 2026
8038a19
Report ambiguous source-map values
sandstream Oct 30, 2026
012b1b2
Continue scans past ambiguous values and fixtures
sandstream Oct 30, 2026
7790fc4
Exclude source maps for test modules
sandstream Oct 30, 2026
2bab998
Preserve findings from incomplete bundle scans
sandstream Oct 30, 2026
7803e73
Align build scan test exclusions
sandstream Oct 30, 2026
7f894aa
Keep findings in truncated maps and scan comparisons
sandstream Oct 30, 2026
7b5f588
Avoid cross-source matches in malformed maps
sandstream Oct 30, 2026
f7aeafd
Bound malformed JSON token scanning
sandstream Oct 30, 2026
bc6f629
Inspect recoverable unfinished source strings
sandstream Oct 30, 2026
092c142
Recover incomplete trailing escapes
sandstream Oct 30, 2026
07a15fa
Describe source-map metadata scan limits
sandstream Oct 30, 2026
b485170
Format source-map regression tests
sandstream Oct 30, 2026
698fb91
Preserve coverage classification for skipped checks
sandstream Oct 30, 2026
a4b3462
Fix CI review findings
sandstream Oct 30, 2026
71b44ea
Update build-scan command contract
sandstream Oct 30, 2026
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
1 change: 1 addition & 0 deletions contracts/kit.opencli.json
Original file line number Diff line number Diff line change
Expand Up @@ -2429,6 +2429,7 @@
"--format",
"--head",
"--help",
"--include-tests",
"--json",
"--non-interactive",
"--only",
Expand Down
8 changes: 8 additions & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,14 @@ max_bytes = 12000
| `kit security prescan <path> [--deep] [--format=json] [--vs-baseline=<p>]` | Multi-repo baseline sweep (secrets, gitignore, branch-protect; `--deep` adds CVE / workflow-drift / bumblebee). |
| `kit security prescan-diff <baseline.jsonl> <latest.jsonl>` | Diff two prescan reports — surface new regressions + fixed findings. |

The build scan reports an incomplete check for source maps over 5 MiB or 100,000 source entries.
It also limits scans of metadata and invalid content to 100,000 string tokens per map.
It also reports incomplete scans for invalid maps and ambiguous keyed values.
The report includes credentials found before a limit.
The scan excludes `__tests__`, `__mocks__`, and `fixtures` directories by default.
It also excludes `.test` and `.spec` JavaScript or TypeScript files and their source maps.
Add `--include-tests` to scan them. Directories named `test` or `tests` are still scanned.

### Code guardrails in the security check

`kit check --category security` scans shell scripts, GitHub workflow commands, Dockerfiles, and source files.
Expand Down
3 changes: 3 additions & 0 deletions src/check-client-exposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,9 +375,12 @@ describe("checkBuiltBundleSecrets", () => {
it("classifies test artifacts by path, including mocks and fixtures", () => {
assert.equal(isTestArtifact("dist/a.test.js"), true);
assert.equal(isTestArtifact("dist/a.spec.mjs"), true);
assert.equal(isTestArtifact("dist/a.test.js.map"), true);
assert.equal(isTestArtifact("dist/a.spec.mjs.map"), true);
assert.equal(isTestArtifact("dist/__tests__/a.js"), true);
assert.equal(isTestArtifact("dist/fixtures/a.js"), true);
assert.equal(isTestArtifact("dist/assets/index-abc.js"), false);
assert.equal(isTestArtifact("dist/latest.js"), false, "a name containing 'test' is not a test");
assert.equal(isTestArtifact("dist/latest.js.map"), false);
});
});
73 changes: 51 additions & 22 deletions src/check-client-exposure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ async function candidatePackageDirs(root: string): Promise<string[]> {
export function isTestArtifact(file: string): boolean {
return (
/(^|[\\/])(__tests__|__mocks__|fixtures)[\\/]/.test(file) ||
/\.(test|spec)\.[cm]?[jt]sx?$/.test(file)
/\.(test|spec)\.[cm]?[jt]sx?(?:\.map)?$/.test(file)
);
}

Expand All @@ -457,6 +457,39 @@ const BUILD_DIRS = [
".output",
];

async function presentBuildDirs(root: string, builds: ClientBuild[]): Promise<string[]> {
const present: string[] = [];
for (const build of builds) {
for (const dir of BUILD_DIRS) {
const rel = build.dir ? `${build.dir}/${dir}` : dir;
try {
await access(join(root, rel));
present.push(rel);
} catch {
/* not built here */
}
}
}
return present;
}

function incompleteBundleResult(
base: Omit<SecurityCheckResult, "status" | "detail">,
reason: string,
known: Array<{ file: string }>,
): SecurityCheckResult {
return {
...base,
status: "fail",
severity: known.length ? "critical" : "medium",
didNotRun: true,
scanIncomplete: true,
...(known.length ? { hasFindings: true } : {}),
detail: `bundle scan could not finish: ${reason.slice(0, 80)}${known.length ? `; credentials also found in ${known.map((h) => h.file).join(", ")}` : ""}`,
...(known.length ? { files: known.map((h) => h.file) } : {}),
};
}

/**
* Scan the built output for credential shapes.
*
Expand All @@ -480,18 +513,7 @@ export async function checkBuiltBundleSecrets(root: string): Promise<SecurityChe
}
const framework = [...new Set(builds.map((b) => b.framework))].join("+");

const present: string[] = [];
for (const build of builds) {
for (const dir of BUILD_DIRS) {
const rel = build.dir ? `${build.dir}/${dir}` : dir;
try {
await access(join(root, rel));
present.push(rel);
} catch {
/* not built here */
}
}
}
const present = await presentBuildDirs(root, builds);
if (present.length === 0) {
return {
...base,
Expand All @@ -500,23 +522,30 @@ export async function checkBuiltBundleSecrets(root: string): Promise<SecurityChe
};
}

const { scanBuildArtifacts } = await import("./scan-build.js");
const { scanBuildArtifacts, BuildScanIncompleteError } = await import("./scan-build.js");
let hits: Awaited<ReturnType<typeof scanBuildArtifacts>>;
let ignoredIncomplete: string[] = [];
try {
hits = await scanBuildArtifacts(root, present);
} catch (e) {
// A scan that crashed is not a clean scan: didNotRun makes the CI gate treat it as such.
return {
...base,
status: "fail",
severity: "medium",
didNotRun: true,
detail: `bundle scan could not run: ${(e as Error).message.slice(0, 80)}`,
};
if (!(e instanceof BuildScanIncompleteError)) {
return incompleteBundleResult(base, (e as Error).message, []);
}
const issues = e.issues.filter((issue) => !isTestArtifact(issue.file));
if (issues.length) {
const known = e.hits.filter((hit) => !isTestArtifact(hit.file));
return incompleteBundleResult(base, issues[0].detail, known);
}
hits = e.hits;
ignoredIncomplete = e.issues.map((issue) => issue.file);
}

const shipped = hits.filter((h) => !isTestArtifact(h.file));
const excluded = hits.length - shipped.length;
const excluded = new Set([
...hits.filter((hit) => isTestArtifact(hit.file)).map((hit) => hit.file),
...ignoredIncomplete,
]).size;
const aside = excluded > 0 ? ` (${excluded} test fixture(s) not counted)` : "";

if (shipped.length === 0) {
Expand Down
28 changes: 28 additions & 0 deletions src/check-mcp-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,35 @@ describe("summarizeCheckRun", () => {
const s = summarizeCheckRun(runFixture());
assert.equal(s.findings.find((f) => f.name === "npm audit")?.severity, "high");
});
});

describe("summarizeCheckRun incomplete findings", () => {
it("preserves known findings on an incomplete check", () => {
const summary = summarizeCheckRun(
runFixture({
security: [
{
category: "exposure",
name: "bundle",
status: "fail",
detail: "finding and incomplete scan",
didNotRun: true,
scanIncomplete: true,
hasFindings: true,
severity: "critical",
},
],
}),
);
const bundle = summary.findings.find((finding) => finding.name === "bundle");
assert.equal(bundle?.didNotRun, true);
assert.equal(bundle?.scanIncomplete, true);
assert.equal(bundle?.hasFindings, true);
assert.equal(bundle?.severity, "critical");
});
});

describe("summarizeCheckRun scope and detail", () => {
it("carries scope, so a narrowed green cannot read as a full one", () => {
const s = summarizeCheckRun(runFixture({ scope: ["security"], ok: true }));
assert.deepEqual(s.scope, ["security"]);
Expand Down
23 changes: 15 additions & 8 deletions src/check-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,20 @@ export async function runCheckGate(opts: RunCheckOptions = {}): Promise<CheckRun
* and `kit review`'s check stage reports — one mapping, so the row shape can't
* fork between surfaces.
*/
/** Preserve both findings and incomplete coverage across check and CI output. */
export function securityCheckToJson(s: SecurityCheckResult): JsonCheck {
return {
name: s.name,
status: s.status,
detail: s.detail,
category: `security/${s.category}`,
...(s.severity ? { severity: s.severity } : {}),
...(s.didNotRun ? { didNotRun: true } : {}),
...(s.hasFindings ? { hasFindings: true } : {}),
...(s.scanIncomplete ? { scanIncomplete: true } : {}),
};
}

export function checkRunToJsonChecks(r: CheckRunResult): JsonCheck[] {
return [
...r.tools.map((t) => ({
Expand Down Expand Up @@ -339,14 +353,7 @@ export function checkRunToJsonChecks(r: CheckRunResult): JsonCheck[] {
// `severity` and `didNotRun` are carried through deliberately: JsonCheck has always
// declared severity and this projection silently dropped it, and didNotRun is what lets
// a consumer tell "stopped failing" from "stopped looking" (see scan-diff.ts).
...r.security.map((s) => ({
name: s.name,
status: s.status,
detail: s.detail,
category: `security/${s.category}`,
...(s.severity ? { severity: s.severity } : {}),
...(s.didNotRun ? { didNotRun: true as const } : {}),
})),
...r.security.map(securityCheckToJson),
...r.tests.map((t) => ({
name: t.name,
status: t.status,
Expand Down
2 changes: 1 addition & 1 deletion src/check-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const SECURITY_CATEGORIES = [

export type SecurityCategory = (typeof SECURITY_CATEGORIES)[number] | `self-audit/${string}`;

export interface SecurityCheckResult {
export interface SecurityCheckResult extends Partial<{ hasFindings: true; scanIncomplete: true }> {
category: SecurityCategory;
name: string;
status: "pass" | "fail" | "warn" | "skip";
Expand Down
34 changes: 31 additions & 3 deletions src/ci.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { writeFile, rm, mkdir } from "node:fs/promises";
import { writeFile, rm, mkdir, mkdtemp } from "node:fs/promises";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";

Expand All @@ -26,8 +26,7 @@ async function runCi(args: string[] = [], env: Record<string, string> = {}) {
}

before(async () => {
testDir = join(tmpdir(), `kit-ci-test-${Date.now()}`);
await mkdir(testDir, { recursive: true });
testDir = await mkdtemp(join(tmpdir(), "kit-ci-test-"));
// Minimal .kit.toml with no required tools/services/secrets
await writeFile(join(testDir, ".kit.toml"), `[tools]\n`, "utf8");
});
Expand Down Expand Up @@ -66,6 +65,35 @@ describe("kit ci", () => {
assert(typeof parsed.ok === "boolean");
});

it("preserves an incomplete security scan and its finding in JSON", async () => {
const dist = join(testDir, "dist");
try {
await mkdir(dist, { recursive: true });
await writeFile(
join(testDir, "package.json"),
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
JSON.stringify({ devDependencies: { vite: "^5" } }),
);
await writeFile(join(dist, "broken.js.map"), "{");
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
await writeFile(join(dist, "leak.js"), "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123");
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
const { stdout } = await runCi(["--json", "--no-auto-install"], { KIT_BUMBLEBEE: "0" });
const output = JSON.parse(stdout);
const check = output.checks.find(
(row: { name: string }) => row.name === "built bundle secrets",
);
assert.ok(check);
assert.equal(check.status, "fail");
assert.equal(check.severity, "critical");
assert.equal(check.didNotRun, true);
assert.equal(check.scanIncomplete, true);
assert.equal(check.hasFindings, true);
assert.match(check.detail, /dist\/leak\.js/);
assert.doesNotMatch(check.detail, /--lenient to downgrade/);
} finally {
await rm(dist, { recursive: true, force: true });
await rm(join(testDir, "package.json"), { force: true });
}
});

it("--format=text outputs a summary line", async () => {
const { stdout } = await runCi(["--format=text"]);
assert(stdout.includes("kit ci:"));
Expand Down
4 changes: 4 additions & 0 deletions src/cli-checks-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export interface JsonCheck {
* "stopped looking" — see `scan-diff.ts`, which ranks lost coverage above a regression.
*/
didNotRun?: boolean;
/** True when the check also reported a finding despite incomplete coverage. */
hasFindings?: boolean;
/** True when the scan started but could not finish. */
scanIncomplete?: boolean;
}

/** The full `--json` document for check / ci. */
Expand Down
6 changes: 3 additions & 3 deletions src/commands/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { checkServices } from "../check-services.js";
import { checkSecrets } from "../check-secrets.js";
import { checkSkills } from "../check-skills.js";
import { checkSecurity, gateStatus } from "../check-security.js";
import { securityCheckToJson } from "../check-run.js";
import { checkLockFiles } from "../check-lock.js";
import { evaluatePolicy } from "../policy-check.js";
import {
Expand Down Expand Up @@ -177,15 +178,14 @@ export async function cmdCi(): Promise<boolean> {
category: "lock",
})),
...securityResults.map((s) => ({
name: s.name,
...securityCheckToJson(s),
// Scanner-health strict by default: a check that could not run fails the
// gate unless --lenient. A finding-warn (the check ran + flagged) is untouched.
status: gateStatus(s, { lenient, failOnWarning }) as JsonCheck["status"],
detail:
s.didNotRun && !lenient
s.didNotRun && !lenient && s.status === "warn"
? `${s.detail} [did not run — strict default; --lenient to downgrade to warn]`
: s.detail,
category: `security/${s.category}`,
})),
...(policyReport.present
? [
Expand Down
Loading
Loading