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
21 changes: 21 additions & 0 deletions src/cli/commands/REPORTS.md
Original file line number Diff line number Diff line change
@@ -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`.
87 changes: 87 additions & 0 deletions src/cli/commands/exposure-report.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
193 changes: 193 additions & 0 deletions src/cli/commands/exposure-report.ts
Original file line number Diff line number Diff line change
@@ -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<ExposureReport> {
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 <path>", "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;
}
});
}
7 changes: 7 additions & 0 deletions src/cli/commands/fixtures/bun-report-workspace/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/cli/commands/fixtures/report-workspace/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const fixture = "safe";
22 changes: 22 additions & 0 deletions src/cli/commands/fixtures/report-workspace/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions src/cli/commands/fixtures/report-workspace/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "shield-report-fixture",
"private": true,
"dependencies": {
"axios": "1.14.1",
"chalk": "5.4.1"
}
}
Loading
Loading