From 41ee7ce01519bc3ed8a778816993b70b0ef5baed Mon Sep 17 00:00:00 2001 From: Andrei Date: Fri, 31 Jul 2026 00:35:22 +0300 Subject: [PATCH 1/3] feat: Add exposure and supply-chain report commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add exposure and supply-chain report commands Implement deterministic agent-facing reports for security triage: shield exposure-report --workspace --history --github-alerts --redact --json --markdown and shield supply-chain report --since 24h --json. Never output raw secrets. Include focused tests/docs. This replaces custom loop scripts for secrets exposure and dependency/security summaries. Re-verify on origin/main before changing anything. Implement `shield exposure-report --workspace --history --redact --json --markdown` and `shield supply-chain report --since 24h --json` as deterministic agent-facing triage reports. HARD CONSTRAINT: never emit a raw secret — every finding carries a kind, a location and a masked excerpt only, and there must be a test proving a planted fixture credential does not appear in any output stream. SCOPE ADJUSTMENT FOR THIS ENVIRONMENT: the original text also asked for `--github-alerts`; there is no network and no token here, so make that flag OPTIONAL and cleanly skipped (reported as 'unavailable', not silently omitted and not an error) when no token/network is present, and implement/verify only the offline halves — filesystem scan, git history scan, and lockfile-based supply-chain reporting. Tests run against a fixture workspace committed to the repo. EXECUTION NOTES (added 2026-07-29 for autonomous execution) Work only in this repo, only in src/. Add tests that fail before the change and pass after it (this codebase is test-driven). Acceptance: `bun install`, `bun run typecheck` (if present), `bun run build` and `bun test` all green. The executing environment has NO network egress, NO credentials and NO live services — everything must work offline against fixtures. X-Factory-Run: run_d3a2abd6dd32 X-Factory-Task: 7ccf1548-235c-4df7-8e13-155392fe6629 --- src/cli/commands/REPORTS.md | 21 ++ src/cli/commands/exposure-report.test.ts | 87 ++++++ src/cli/commands/exposure-report.ts | 193 ++++++++++++ .../fixtures/bun-report-workspace/bun.lock | 7 + .../commands/fixtures/report-workspace/app.ts | 1 + .../report-workspace/package-lock.json | 22 ++ .../fixtures/report-workspace/package.json | 8 + src/cli/commands/supply-chain-report.test.ts | 76 +++++ src/cli/commands/supply-chain-report.ts | 286 ++++++++++++++++++ src/cli/index.tsx | 4 + 10 files changed, 705 insertions(+) create mode 100644 src/cli/commands/REPORTS.md create mode 100644 src/cli/commands/exposure-report.test.ts create mode 100644 src/cli/commands/exposure-report.ts create mode 100644 src/cli/commands/fixtures/bun-report-workspace/bun.lock create mode 100644 src/cli/commands/fixtures/report-workspace/app.ts create mode 100644 src/cli/commands/fixtures/report-workspace/package-lock.json create mode 100644 src/cli/commands/fixtures/report-workspace/package.json create mode 100644 src/cli/commands/supply-chain-report.test.ts create mode 100644 src/cli/commands/supply-chain-report.ts diff --git a/src/cli/commands/REPORTS.md b/src/cli/commands/REPORTS.md new file mode 100644 index 0000000..7887cf1 --- /dev/null +++ b/src/cli/commands/REPORTS.md @@ -0,0 +1,21 @@ +# Agent triage reports + +## Exposure report + +```sh +shield exposure-report --workspace . --history --github-alerts --redact --json +shield exposure-report --workspace . --history --redact --markdown +``` + +The report scans workspace files and, with `--history`, local git history. Findings are deterministically sorted and contain only `kind`, `location`, and `maskedExcerpt`; raw matched values are never emitted. `--redact` documents the invariant and cannot disable masking. In offline environments, requested `--github-alerts` are reported as `unavailable` without failing the command. + +`--markdown` selects Markdown output; otherwise output is JSON (`--json` makes that choice explicit). + +## Supply-chain report + +```sh +shield supply-chain report --since 24h --json +shield supply-chain report --workspace ./service --since 7d --json +``` + +The report recursively reads supported npm-family lockfiles without registry or network access, sorts dependency records, lists local git lockfile changes in the `--since` lookback, and matches exact locked versions against Shield's bundled advisories. Supported duration suffixes are `m`, `h`, `d`, and `w`. diff --git a/src/cli/commands/exposure-report.test.ts b/src/cli/commands/exposure-report.test.ts new file mode 100644 index 0000000..e20c4bc --- /dev/null +++ b/src/cli/commands/exposure-report.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { cpSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Command } from "commander"; +import { + buildExposureReport, + formatExposureReportJson, + formatExposureReportMarkdown, + registerExposureReportCommand, +} from "./exposure-report.js"; + +const fixturePath = join(import.meta.dir, "fixtures", "report-workspace"); +const plantedCredential = ["ghp", "fixtureCredentialMustNeverAppear1234567890"].join("_"); + +describe("exposure report", () => { + let workspace = ""; + + afterEach(() => { + if (workspace) rmSync(workspace, { recursive: true, force: true }); + }); + + function createWorkspace(): string { + workspace = mkdtempSync(join(tmpdir(), "shield-exposure-report-")); + cpSync(fixturePath, workspace, { recursive: true }); + return workspace; + } + + test("reports filesystem and removed history findings deterministically", async () => { + const path = createWorkspace(); + execFileSync("git", ["init", "-q"], { cwd: path }); + execFileSync("git", ["config", "user.email", "fixture@example.test"], { cwd: path }); + execFileSync("git", ["config", "user.name", "Fixture"], { cwd: path }); + writeFileSync(join(path, "history.env"), `TOKEN=${plantedCredential}\n`); + execFileSync("git", ["add", "."], { cwd: path }); + execFileSync("git", ["commit", "-qm", "plant fixture"], { cwd: path }); + rmSync(join(path, "history.env")); + writeFileSync(join(path, "current.env"), `TOKEN=${plantedCredential}\n`); + execFileSync("git", ["add", "-A"], { cwd: path }); + execFileSync("git", ["commit", "-qm", "move fixture"], { cwd: path }); + + const first = await buildExposureReport({ workspace: path, history: true, githubAlerts: true }); + const second = await buildExposureReport({ workspace: path, history: true, githubAlerts: true }); + const json = formatExposureReportJson(first); + const markdown = formatExposureReportMarkdown(first); + const allOutput = `${json}\n${markdown}`; + + expect(second).toEqual(first); + expect(first.sources).toEqual({ + filesystem: "available", + gitHistory: "available", + githubAlerts: "unavailable", + }); + expect(first.findings.some((finding) => finding.location.source === "filesystem")).toBe(true); + expect(first.findings.some((finding) => finding.location.source === "git-history")).toBe(true); + expect(first.findings.every((finding) => finding.kind && finding.location.path && finding.maskedExcerpt)).toBe(true); + expect(first.findings.every((finding) => !finding.maskedExcerpt.includes(plantedCredential))).toBe(true); + expect(allOutput).not.toContain(plantedCredential); + expect(markdown).toContain("GitHub alerts | unavailable"); + }); + + test("never writes a planted credential to stdout or stderr", async () => { + const path = createWorkspace(); + writeFileSync(join(path, "credential.env"), `TOKEN=${plantedCredential}\n`); + const stdout: string[] = []; + const stderr: string[] = []; + + const program = new Command(); + program.exitOverride(); + program.configureOutput({ + writeOut: (value) => stdout.push(value), + writeErr: (value) => stderr.push(value), + }); + registerExposureReportCommand(program, { + stdout: (value) => stdout.push(value), + stderr: (value) => stderr.push(value), + }); + await program.parseAsync([ + "node", "shield", "exposure-report", "--workspace", path, "--redact", "--json", + ]); + + const allOutput = [...stdout, ...stderr].join(""); + expect(allOutput).not.toContain(plantedCredential); + expect(JSON.parse(stdout.join(""))).toMatchObject({ report: "shield-exposure-report" }); + }); +}); diff --git a/src/cli/commands/exposure-report.ts b/src/cli/commands/exposure-report.ts new file mode 100644 index 0000000..07a692c --- /dev/null +++ b/src/cli/commands/exposure-report.ts @@ -0,0 +1,193 @@ +import { execFileSync } from "node:child_process"; +import { isAbsolute, relative, resolve } from "node:path"; +import type { Command } from "commander"; +import { + sanitizeLocationForOutput, + sanitizeTextForBoundary, + sanitizeValueForBoundary, +} from "../../lib/finding-safety.js"; +import { scanSecretExposure } from "../../lib/secret-exposure.js"; +import { ScannerType, type FindingInput } from "../../types/index.js"; + +export type ExposureSourceStatus = "available" | "unavailable" | "not-requested"; + +export interface ExposureReportFinding { + kind: string; + location: { + source: "filesystem" | "git-history" | "github-alerts"; + path: string; + line: number; + }; + maskedExcerpt: string; +} + +export interface ExposureReport { + schemaVersion: 1; + report: "shield-exposure-report"; + sources: { + filesystem: ExposureSourceStatus; + gitHistory: ExposureSourceStatus; + githubAlerts: ExposureSourceStatus; + }; + summary: { + total: number; + filesystem: number; + gitHistory: number; + githubAlerts: number; + }; + findings: ExposureReportFinding[]; +} + +export interface ExposureReportOptions { + workspace: string; + history?: boolean; + githubAlerts?: boolean; +} + +export interface ReportWriters { + stdout: (value: string) => void; + stderr: (value: string) => void; +} + +const defaultWriters: ReportWriters = { + stdout: (value) => process.stdout.write(value), + stderr: (value) => process.stderr.write(value), +}; + +function hasGitHistory(workspace: string): boolean { + try { + return execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { + cwd: workspace, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim() === "true"; + } catch { + return false; + } +} + +function findingPath(workspace: string, finding: FindingInput): string { + const path = isAbsolute(finding.file) ? relative(workspace, finding.file) : finding.file; + const normalized = path.replaceAll("\\", "/") || "."; + return sanitizeLocationForOutput(normalized.startsWith("../") ? "[OUTSIDE-WORKSPACE]" : normalized); +} + +function toReportFinding(workspace: string, finding: FindingInput): ExposureReportFinding { + const source = finding.scanner_type === ScannerType.GitHistory ? "git-history" : "filesystem"; + const kind = sanitizeTextForBoundary(finding.rule_id.replace(/^git-/, ""), 128); + return { + kind, + location: { + source, + path: findingPath(workspace, finding), + line: Math.max(1, finding.line), + }, + maskedExcerpt: `[MASKED ${kind}]`, + }; +} + +function compareFindings(left: ExposureReportFinding, right: ExposureReportFinding): number { + return left.location.source.localeCompare(right.location.source) + || left.location.path.localeCompare(right.location.path) + || left.location.line - right.location.line + || left.kind.localeCompare(right.kind); +} + +export async function buildExposureReport(options: ExposureReportOptions): Promise { + const workspace = resolve(options.workspace); + const historyAvailable = options.history === true && hasGitHistory(workspace); + const exposure = await scanSecretExposure({ + path: workspace, + include_git_history: historyAvailable, + include_processes: false, + include_tmux: false, + }); + const findings = exposure.findings.map((finding) => toReportFinding(workspace, finding)).sort(compareFindings); + const filesystem = findings.filter((finding) => finding.location.source === "filesystem").length; + const gitHistory = findings.filter((finding) => finding.location.source === "git-history").length; + + return sanitizeValueForBoundary({ + schemaVersion: 1, + report: "shield-exposure-report", + sources: { + filesystem: "available", + gitHistory: options.history === true + ? historyAvailable ? "available" : "unavailable" + : "not-requested", + githubAlerts: options.githubAlerts === true ? "unavailable" : "not-requested", + }, + summary: { + total: findings.length, + filesystem, + gitHistory, + githubAlerts: 0, + }, + findings, + } satisfies ExposureReport); +} + +export function formatExposureReportJson(report: ExposureReport): string { + return `${JSON.stringify(sanitizeValueForBoundary(report), null, 2)}\n`; +} + +function markdownCell(value: string | number): string { + return sanitizeTextForBoundary(String(value), 512).replaceAll("|", "\\|"); +} + +export function formatExposureReportMarkdown(report: ExposureReport): string { + const safe = sanitizeValueForBoundary(report); + const lines = [ + "# Shield Exposure Report", + "", + "## Sources", + "", + "| Source | Status |", + "| --- | --- |", + `| Filesystem | ${safe.sources.filesystem} |`, + `| Git history | ${safe.sources.gitHistory} |`, + `| GitHub alerts | ${safe.sources.githubAlerts} |`, + "", + `Findings: ${safe.summary.total}`, + "", + "| Kind | Source | Location | Masked excerpt |", + "| --- | --- | --- | --- |", + ...safe.findings.map((finding) => + `| ${markdownCell(finding.kind)} | ${finding.location.source} | ${markdownCell(`${finding.location.path}:${finding.location.line}`)} | ${markdownCell(finding.maskedExcerpt)} |`), + "", + ]; + return lines.join("\n"); +} + +export function registerExposureReportCommand( + program: Command, + writers: ReportWriters = defaultWriters, +): void { + program + .command("exposure-report") + .description("Produce a deterministic, redacted secret exposure triage report") + .requiredOption("--workspace ", "Workspace to scan") + .option("--history", "Include repository git history", false) + .option("--github-alerts", "Include GitHub alerts when available", false) + .option("--redact", "Mask all finding excerpts (always enforced)", false) + .option("--json", "Output JSON", false) + .option("--markdown", "Output Markdown", false) + .action(async (options: { + workspace: string; + history: boolean; + githubAlerts: boolean; + redact: boolean; + json: boolean; + markdown: boolean; + }) => { + try { + const report = await buildExposureReport(options); + writers.stdout(options.markdown + ? formatExposureReportMarkdown(report) + : formatExposureReportJson(report)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writers.stderr(`${sanitizeTextForBoundary(message)}\n`); + process.exitCode = 1; + } + }); +} diff --git a/src/cli/commands/fixtures/bun-report-workspace/bun.lock b/src/cli/commands/fixtures/bun-report-workspace/bun.lock new file mode 100644 index 0000000..7b1aadf --- /dev/null +++ b/src/cli/commands/fixtures/bun-report-workspace/bun.lock @@ -0,0 +1,7 @@ +{ + "lockfileVersion": 1, + "packages": { + "axios": ["axios@1.14.1", "", {}, "sha512-fixture-axios"], + "chalk": ["chalk@5.4.1", "", {}, "sha512-fixture-chalk"], + }, +} diff --git a/src/cli/commands/fixtures/report-workspace/app.ts b/src/cli/commands/fixtures/report-workspace/app.ts new file mode 100644 index 0000000..2fcc7cd --- /dev/null +++ b/src/cli/commands/fixtures/report-workspace/app.ts @@ -0,0 +1 @@ +export const fixture = "safe"; diff --git a/src/cli/commands/fixtures/report-workspace/package-lock.json b/src/cli/commands/fixtures/report-workspace/package-lock.json new file mode 100644 index 0000000..c3449e0 --- /dev/null +++ b/src/cli/commands/fixtures/report-workspace/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "shield-report-fixture", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shield-report-fixture", + "dependencies": { + "axios": "1.14.1", + "chalk": "5.4.1" + } + }, + "node_modules/axios": { + "version": "1.14.1", + "integrity": "sha512-fixture-axios" + }, + "node_modules/chalk": { + "version": "5.4.1", + "integrity": "sha512-fixture-chalk" + } + } +} diff --git a/src/cli/commands/fixtures/report-workspace/package.json b/src/cli/commands/fixtures/report-workspace/package.json new file mode 100644 index 0000000..184dbde --- /dev/null +++ b/src/cli/commands/fixtures/report-workspace/package.json @@ -0,0 +1,8 @@ +{ + "name": "shield-report-fixture", + "private": true, + "dependencies": { + "axios": "1.14.1", + "chalk": "5.4.1" + } +} diff --git a/src/cli/commands/supply-chain-report.test.ts b/src/cli/commands/supply-chain-report.test.ts new file mode 100644 index 0000000..04c9e00 --- /dev/null +++ b/src/cli/commands/supply-chain-report.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { cpSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Command } from "commander"; +import { + buildSupplyChainReport, + formatSupplyChainReportJson, + registerSupplyChainReportCommand, +} from "./supply-chain-report.js"; + +const fixturePath = join(import.meta.dir, "fixtures", "report-workspace"); +const bunFixturePath = join(import.meta.dir, "fixtures", "bun-report-workspace"); + +describe("supply-chain report", () => { + let workspace = ""; + + afterEach(() => { + if (workspace) rmSync(workspace, { recursive: true, force: true }); + }); + + test("summarizes lockfiles and dependencies deterministically", async () => { + workspace = mkdtempSync(join(tmpdir(), "shield-supply-report-")); + cpSync(fixturePath, workspace, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: workspace }); + execFileSync("git", ["config", "user.email", "fixture.test"], { cwd: workspace }); + execFileSync("git", ["config", "user.name", "Fixture"], { cwd: workspace }); + execFileSync("git", ["add", "."], { cwd: workspace }); + execFileSync("git", ["commit", "-qm", "add lockfile"], { cwd: workspace }); + + const first = await buildSupplyChainReport({ workspace, since: "24h" }); + const second = await buildSupplyChainReport({ workspace, since: "24h" }); + const output = formatSupplyChainReportJson(first); + + expect(second).toEqual(first); + expect(first).toMatchObject({ + report: "shield-supply-chain-report", + since: "24h", + summary: { lockfiles: 1, dependencies: 2, changes: 1 }, + }); + expect(first.changes).toEqual([{ + commit: expect.stringMatching(/^[a-f0-9]{12}$/), + lockfile: "package-lock.json", + }]); + expect(first.dependencies.map((dependency) => `${dependency.name}@${dependency.version}`)).toEqual([ + "axios@1.14.1", + "chalk@5.4.1", + ]); + expect(output).toBe(`${JSON.stringify(first, null, 2)}\n`); + }); + + test("parses text-based bun.lock files offline", async () => { + workspace = mkdtempSync(join(tmpdir(), "shield-bun-supply-report-")); + cpSync(bunFixturePath, workspace, { recursive: true }); + + const report = await buildSupplyChainReport({ workspace, since: "24h" }); + + expect(report.summary).toMatchObject({ lockfiles: 1, dependencies: 2 }); + expect(report.dependencies.map((dependency) => `${dependency.name}@${dependency.version}`)).toEqual([ + "axios@1.14.1", + "chalk@5.4.1", + ]); + }); + + test("registers shield supply-chain report --since 24h --json", () => { + const program = new Command(); + registerSupplyChainReportCommand(program); + const supplyChain = program.commands.find((command) => command.name() === "supply-chain"); + const report = supplyChain?.commands.find((command) => command.name() === "report"); + + expect(report).toBeDefined(); + expect(report?.options.some((option) => option.long === "--since")).toBe(true); + expect(report?.options.some((option) => option.long === "--json")).toBe(true); + }); +}); diff --git a/src/cli/commands/supply-chain-report.ts b/src/cli/commands/supply-chain-report.ts new file mode 100644 index 0000000..eae1911 --- /dev/null +++ b/src/cli/commands/supply-chain-report.ts @@ -0,0 +1,286 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { relative, resolve } from "node:path"; +import type { Command } from "commander"; +import { SEED_ADVISORIES } from "../../data/advisories.js"; +import { sanitizeTextForBoundary, sanitizeValueForBoundary } from "../../lib/finding-safety.js"; +import type { ReportWriters } from "./exposure-report.js"; + +export interface SupplyChainDependency { + ecosystem: "npm"; + name: string; + version: string; + lockfile: string; +} + +export interface SupplyChainFinding { + kind: string; + location: { + source: "lockfile"; + path: string; + line: number; + }; + maskedExcerpt: string; +} + +export interface SupplyChainChange { + commit: string; + lockfile: string; +} + +export interface SupplyChainReport { + schemaVersion: 1; + report: "shield-supply-chain-report"; + since: string; + summary: { + lockfiles: number; + dependencies: number; + changes: number; + findings: number; + }; + lockfiles: string[]; + changes: SupplyChainChange[]; + dependencies: SupplyChainDependency[]; + findings: SupplyChainFinding[]; +} + +export interface SupplyChainReportOptions { + workspace?: string; + since: string; +} + +const LOCKFILE_NAMES = new Set(["package-lock.json", "npm-shrinkwrap.json", "bun.lock", "yarn.lock", "pnpm-lock.yaml"]); +const IGNORED_DIRECTORIES = new Set([".git", "node_modules", "dist", "build", "vendor"]); + +function validateSince(value: string): string { + if (!/^[1-9]\d*(?:m|h|d|w)$/.test(value)) { + throw new Error(`Invalid --since value '${sanitizeTextForBoundary(value)}'. Expected values such as 30m, 24h, or 7d.`); + } + return value; +} + +function findLockfiles(workspace: string): string[] { + const lockfiles: string[] = []; + + function walk(directory: string): void { + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) { + if (entry.isSymbolicLink()) continue; + const path = `${directory}/${entry.name}`; + if (entry.isDirectory()) { + if (!IGNORED_DIRECTORIES.has(entry.name)) walk(path); + } else if (entry.isFile() && LOCKFILE_NAMES.has(entry.name)) { + lockfiles.push(path); + } + } + } + + walk(workspace); + return lockfiles; +} + +function packageNameFromPath(path: string): string | null { + const segments = path.replaceAll("\\", "/").split("/"); + for (let index = segments.length - 1; index >= 0; index--) { + if (segments[index] !== "node_modules") continue; + const name = segments[index + 1]; + if (!name) return null; + return name.startsWith("@") && segments[index + 2] ? `${name}/${segments[index + 2]}` : name; + } + return null; +} + +function parseJsonLockfile(content: string): Array<{ name: string; version: string }> { + const normalized = content + .replace(/\/\/.*$/gm, "") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/,(\s*[}\]])/g, "$1"); + const parsed = JSON.parse(normalized) as { + packages?: Record; + dependencies?: Record; + }; + const dependencies: Array<{ name: string; version: string }> = []; + + for (const [path, value] of Object.entries(parsed.packages ?? {})) { + if (!path) continue; + if (Array.isArray(value) && typeof value[0] === "string") { + const separator = value[0].lastIndexOf("@"); + if (separator > 0) dependencies.push({ + name: value[0].slice(0, separator), + version: value[0].slice(separator + 1), + }); + continue; + } + if (Array.isArray(value) || !value.version) continue; + const name = value.name ?? packageNameFromPath(path); + if (name) dependencies.push({ name, version: value.version }); + } + if (dependencies.length === 0) { + for (const [name, value] of Object.entries(parsed.dependencies ?? {})) { + if (value.version) dependencies.push({ name, version: value.version }); + } + } + return dependencies; +} + +function parseYarnLock(content: string): Array<{ name: string; version: string }> { + const dependencies: Array<{ name: string; version: string }> = []; + for (const block of content.split(/\n(?=\S)/)) { + const descriptor = block.split("\n", 1)[0]?.replace(/:\s*$/, "").replace(/^"|"$/g, "").split(",", 1)[0]; + const version = block.match(/\n\s+version\s+"([^"]+)"/)?.[1]; + if (!descriptor || !version) continue; + const separator = descriptor.startsWith("@") ? descriptor.indexOf("@", descriptor.indexOf("/") + 1) : descriptor.indexOf("@"); + if (separator > 0) dependencies.push({ name: descriptor.slice(0, separator), version }); + } + return dependencies; +} + +function parsePnpmLock(content: string): Array<{ name: string; version: string }> { + const dependencies: Array<{ name: string; version: string }> = []; + const pattern = /(?:^|\n)\s*(?:["'])?\/?((?:@[^/\s'":]+\/)?[^@\s'":]+)@([^:\s'"]+)(?:["'])?:/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(content)) !== null) { + dependencies.push({ name: match[1], version: match[2].split("(", 1)[0] }); + } + return dependencies; +} + +function parseLockfile(path: string): Array<{ name: string; version: string }> { + const content = readFileSync(path, "utf-8"); + if (path.endsWith("yarn.lock")) return parseYarnLock(content); + if (path.endsWith("pnpm-lock.yaml")) return parsePnpmLock(content); + try { + return parseJsonLockfile(content); + } catch { + return []; + } +} + +function findRecentChanges(workspace: string, lockfiles: string[], since: string): SupplyChainChange[] { + if (lockfiles.length === 0) return []; + try { + const output = execFileSync( + "git", + ["log", `--since=${since}`, "--format=COMMIT:%H", "--name-only", "--", ...lockfiles], + { + cwd: workspace, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + maxBuffer: 10 * 1024 * 1024, + }, + ); + const changes: SupplyChainChange[] = []; + let commit = ""; + for (const line of output.split(/\r?\n/)) { + if (line.startsWith("COMMIT:")) { + commit = line.slice(7, 19); + } else if (commit && line.trim()) { + changes.push(sanitizeValueForBoundary({ + commit, + lockfile: line.trim().replaceAll("\\", "/"), + })); + } + } + return changes.sort((left, right) => left.lockfile.localeCompare(right.lockfile) + || left.commit.localeCompare(right.commit)); + } catch { + return []; + } +} + +function compareDependencies(left: SupplyChainDependency, right: SupplyChainDependency): number { + return left.name.localeCompare(right.name) + || left.version.localeCompare(right.version) + || left.lockfile.localeCompare(right.lockfile); +} + +export async function buildSupplyChainReport(options: SupplyChainReportOptions): Promise { + const workspace = resolve(options.workspace ?? "."); + if (!existsSync(workspace)) throw new Error("Workspace does not exist"); + const since = validateSince(options.since); + const absoluteLockfiles = findLockfiles(workspace); + const lockfiles = absoluteLockfiles.map((path) => relative(workspace, path).replaceAll("\\", "/")).sort(); + const changes = findRecentChanges(workspace, lockfiles, since); + const seen = new Set(); + const dependencies: SupplyChainDependency[] = []; + + for (let index = 0; index < absoluteLockfiles.length; index++) { + for (const dependency of parseLockfile(absoluteLockfiles[index])) { + const safeDependency = sanitizeValueForBoundary({ + ecosystem: "npm" as const, + name: dependency.name, + version: dependency.version, + lockfile: lockfiles[index], + }); + const key = `${safeDependency.name}\0${safeDependency.version}\0${safeDependency.lockfile}`; + if (seen.has(key)) continue; + seen.add(key); + dependencies.push(safeDependency); + } + } + dependencies.sort(compareDependencies); + + const findings: SupplyChainFinding[] = []; + for (const dependency of dependencies) { + for (const advisory of SEED_ADVISORIES) { + if (advisory.ecosystem !== "npm" || advisory.package_name !== dependency.name) continue; + if (!advisory.affected_versions.includes("*") && !advisory.affected_versions.includes(dependency.version)) continue; + findings.push({ + kind: advisory.attack_type, + location: { source: "lockfile", path: dependency.lockfile, line: 1 }, + maskedExcerpt: `[MASKED ${dependency.name}@${dependency.version} ${advisory.severity}]`, + }); + } + } + findings.sort((left, right) => left.location.path.localeCompare(right.location.path) + || left.kind.localeCompare(right.kind) + || left.maskedExcerpt.localeCompare(right.maskedExcerpt)); + + return sanitizeValueForBoundary({ + schemaVersion: 1, + report: "shield-supply-chain-report", + since, + summary: { + lockfiles: lockfiles.length, + dependencies: dependencies.length, + changes: changes.length, + findings: findings.length, + }, + lockfiles, + changes, + dependencies, + findings, + } satisfies SupplyChainReport); +} + +export function formatSupplyChainReportJson(report: SupplyChainReport): string { + return `${JSON.stringify(sanitizeValueForBoundary(report), null, 2)}\n`; +} + +const defaultWriters: ReportWriters = { + stdout: (value) => process.stdout.write(value), + stderr: (value) => process.stderr.write(value), +}; + +export function registerSupplyChainReportCommand( + program: Command, + writers: ReportWriters = defaultWriters, +): void { + const supplyChain = program.commands.find((command) => command.name() === "supply-chain") + ?? program.command("supply-chain").description("Produce offline supply-chain triage reports"); + + supplyChain + .command("report") + .description("Summarize lockfile dependencies and bundled security advisories") + .option("--workspace ", "Workspace to inspect", ".") + .option("--since ", "Triage lookback label (for example 24h)", "24h") + .option("--json", "Output JSON", false) + .action(async (options: { workspace: string; since: string; json: boolean }) => { + try { + writers.stdout(formatSupplyChainReportJson(await buildSupplyChainReport(options))); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writers.stderr(`${sanitizeTextForBoundary(message)}\n`); + process.exitCode = 1; + } + }); +} diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 05fb84b..940a841 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -14,6 +14,8 @@ import { registerAlertsCommand } from "./commands/alerts.js"; import { registerSecretsCommand } from "./commands/secrets.js"; import { registerFleetPackageCommand } from "./commands/fleet-package.js"; import { registerOssSecretPolicyCommand } from "./commands/oss-secret-policy.js"; +import { registerExposureReportCommand } from "./commands/exposure-report.js"; +import { registerSupplyChainReportCommand } from "./commands/supply-chain-report.js"; const program = new Command(); @@ -34,6 +36,8 @@ registerAlertsCommand(program); registerSecretsCommand(program); registerFleetPackageCommand(program); registerOssSecretPolicyCommand(program); +registerExposureReportCommand(program); +registerSupplyChainReportCommand(program); registerEventsCommands(program, { source: "shield" }); program.parse(); From 530ecd4150ddba5c18be58a8f20768ecfefe39ab Mon Sep 17 00:00:00 2001 From: Andrei Date: Sat, 1 Aug 2026 01:02:10 +0300 Subject: [PATCH 2/3] fix: normalize supply-chain report git since Agent: Augustus --- src/cli/commands/supply-chain-report.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/supply-chain-report.ts b/src/cli/commands/supply-chain-report.ts index eae1911..c7d64b8 100644 --- a/src/cli/commands/supply-chain-report.ts +++ b/src/cli/commands/supply-chain-report.ts @@ -59,6 +59,19 @@ function validateSince(value: string): string { return value; } +function gitSinceSpec(value: string): string { + const match = /^([1-9]\d*)(m|h|d|w)$/.exec(validateSince(value)); + if (!match) throw new Error("Invalid --since value"); + const amount = Number.parseInt(match[1], 10); + const unit = { + m: "minute", + h: "hour", + d: "day", + w: "week", + }[match[2]]; + return `${amount} ${unit}${amount === 1 ? "" : "s"} ago`; +} + function findLockfiles(workspace: string): string[] { const lockfiles: string[] = []; @@ -160,7 +173,7 @@ function findRecentChanges(workspace: string, lockfiles: string[], since: string try { const output = execFileSync( "git", - ["log", `--since=${since}`, "--format=COMMIT:%H", "--name-only", "--", ...lockfiles], + ["log", `--since=${gitSinceSpec(since)}`, "--format=COMMIT:%H", "--name-only", "--", ...lockfiles], { cwd: workspace, encoding: "utf-8", From d0a10d36bdcd3d0743fdfee8ec302b10dfe002a2 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 1 Aug 2026 13:26:24 +0300 Subject: [PATCH 3/3] fix: parse bun lockfile integrity hashes Agent: Augustus --- .../fixtures/bun-report-workspace/bun.lock | 2 +- src/cli/commands/supply-chain-report.ts | 63 +++++++++++++++++-- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/cli/commands/fixtures/bun-report-workspace/bun.lock b/src/cli/commands/fixtures/bun-report-workspace/bun.lock index 7b1aadf..36bb9fb 100644 --- a/src/cli/commands/fixtures/bun-report-workspace/bun.lock +++ b/src/cli/commands/fixtures/bun-report-workspace/bun.lock @@ -1,7 +1,7 @@ { "lockfileVersion": 1, "packages": { - "axios": ["axios@1.14.1", "", {}, "sha512-fixture-axios"], + "axios": ["axios@1.14.1", "", {}, "sha512-h///fixture-axios"], "chalk": ["chalk@5.4.1", "", {}, "sha512-fixture-chalk"], }, } diff --git a/src/cli/commands/supply-chain-report.ts b/src/cli/commands/supply-chain-report.ts index c7d64b8..ba4774a 100644 --- a/src/cli/commands/supply-chain-report.ts +++ b/src/cli/commands/supply-chain-report.ts @@ -102,11 +102,66 @@ function packageNameFromPath(path: string): string | null { return null; } +function normalizeJsonLikeLockfile(content: string): string { + let result = ""; + let quote: string | null = null; + let escaped = false; + + for (let index = 0; index < content.length; index++) { + const char = content[index]; + const next = content[index + 1]; + + if (quote) { + result += char; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = null; + } + continue; + } + + if (char === "\"" || char === "'") { + quote = char; + result += char; + continue; + } + + if (char === "/" && next === "/") { + while (index < content.length && content[index] !== "\n" && content[index] !== "\r") { + index++; + } + index--; + continue; + } + + if (char === "/" && next === "*") { + index += 2; + while (index < content.length && !(content[index] === "*" && content[index + 1] === "/")) { + index++; + } + index++; + continue; + } + + if (char === ",") { + let cursor = index + 1; + while (/\s/.test(content[cursor] ?? "")) { + cursor++; + } + if (content[cursor] === "}" || content[cursor] === "]") continue; + } + + result += char; + } + + return result; +} + function parseJsonLockfile(content: string): Array<{ name: string; version: string }> { - const normalized = content - .replace(/\/\/.*$/gm, "") - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/,(\s*[}\]])/g, "$1"); + const normalized = normalizeJsonLikeLockfile(content); const parsed = JSON.parse(normalized) as { packages?: Record; dependencies?: Record;