Skip to content
75 changes: 66 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,16 @@ bun install -g @hasna/shield
# Scan your repo for security issues
shield scan .

# Focused secret-exposure scan (repo files, git history, processes, tmux)
# Wider sources are separate, per-invocation opt-ins
shield scan . --git-history
shield scan . --system

# Focused secret-exposure scan (safe default: repository files only)
shield secrets .

# Explicit historical scan (still redacted in terminal/JSON/SARIF output)
shield secrets . --git-history

# Publishable OSS policy check with redacted output
shield oss-secrets-policy . --strict

Expand Down Expand Up @@ -51,8 +58,8 @@ shield init --install-pre-push
| `git-history` | Secrets committed in git history |
| `config` | Insecure CORS, debug mode, missing security headers |
| `ai-safety` | Prompt injection, PII exposure, unsafe tool use |
| `ioc` | Supply chain attack indicators (C2 domains, RAT artifacts, malicious packages) |
| `lockfile` | Compromised locked versions, unpinned ranges during attack windows |
| `ioc` | In-tree C2/malicious-package indicators; host RAT/Python paths require `--system` |
| `lockfile` | Compromised locked versions and unpinned ranges; history requires `--git-history` |
| `supply-chain` | Typosquatting, postinstall exploits, GitHub Actions tag hijacking |

## Supply Chain Attack Detection
Expand Down Expand Up @@ -126,11 +133,17 @@ API endpoints:
- `GET /api/findings` — query scan findings
- `POST /api/scans` — trigger a new scan

CLI, library, SDK, MCP, REST, and dashboard-triggered aggregate scans inspect
only the requested filesystem tree by default. REST/SDK/MCP callers must send
`include_git_history: true` or `include_system: true` for the corresponding
wider source. Merely listing `git-history` in a REST/MCP scanner array does not
authorize history access.

## All CLI Commands

```
shield scan [path] Run shield scan
shield secrets [options] [path] Focused secret-exposure scan (files + live context)
shield secrets [options] [path] Focused secret-exposure scan (file-only by default)
shield oss-secrets-policy [roots...] Evaluate publishable OSS secret-scan policy
shield findings List findings
shield explain <id> AI explanation for a finding
Expand All @@ -155,21 +168,65 @@ Stored in `~/.hasna/security/` (override with `SECURITY_DB` env var).

## Secret Exposure Workflow

`shield secrets` combines four sources:
`shield secrets` scans repository files by default. The following additional
sources exist, but each requires an explicit opt-in because it crosses a wider
data boundary:

- repository files such as `.env` files and config files
- git history across all branches
- running process environments
- tmux pane/session metadata plus recent pane history
- `--git-history` scans git history across all branches
- `--processes` inspects running process command/environment snapshots
- `--tmux` inspects tmux pane/session metadata plus recent pane history

Secret and credential findings never emit raw code snippets. Terminal, JSON,
and SARIF reporters retain the rule, location, severity, and fingerprint while
replacing sensitive snippets and analysis text with `[REDACTED]`. Credential
findings are also excluded from LLM explanation, triage, analysis, and fix
context so source lines cannot cross a model boundary. Secret-scan error output
also withholds underlying exception text because parser or provider errors can
contain scanned source context.

Useful flags:

```bash
shield secrets . --repo-only
# Safe file-only modes (the default, plus an explicit fail-closed form)
shield secrets .
shield secrets . --files-only --json

# Historical source: explicit opt-in
shield secrets . --git-history --json

# Live sources: sensitive explicit opt-in; never use in routine CI
shield secrets . --processes
shield secrets . --tmux

# --repo-only blocks live sources; history still requires --git-history
shield secrets . --repo-only --git-history
shield secrets . --json
shield secrets . --severity high --fail-on medium

# Package/archive-only validation does not inspect ambient processes or tmux
shield fleet-package ./package.tgz --json
```

### Migration warning for 0.1.25 and earlier

Versions through 0.1.25 allowed aggregate and focused paths to cross historical
or live-machine boundaries without a consistent per-invocation opt-in.
Structured output could therefore include credential-bearing source context.
Upgrade before using Shield in an agent, CI job, log collector, or
transcript-producing tool. Until the fixed version is installed,
use `shield secrets . --repo-only --no-git-history --no-processes --no-tmux`
or use the `secrets scan workspace` and `shield fleet-package` file/archive
paths. If an older structured scan ran in a credential-bearing environment,
treat the visible credential identifiers as exposed, preserve values out of
incident channels, and follow the owning vault/provider rotation runbook.
Existing finding rows are sanitized on read and the sanitized fields are then
written back when the local database is writable. Stable non-sensitive hashes
retain correlation without retaining the credential-bearing location or rule
identifier. A read-only database still receives sanitized API/MCP/reporter
output, but cannot be rewritten in place. Credential-finding fingerprints may
change once newly scanned records use the redacted persistence form.

For publishable OSS packages, see
[`docs/oss-secret-scan-policy.md`](docs/oss-secret-scan-policy.md). The policy
requires a `check:secrets` script, prepublish/prepack coverage, release or CI
Expand Down
115 changes: 115 additions & 0 deletions sdk/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { afterEach, describe, expect, test } from "bun:test";
import { spawn, type ChildProcess } from "child_process";
import { mkdirSync, mkdtempSync, rmSync } from "fs";
import { createServer } from "net";
import { tmpdir } from "os";
import { join, resolve } from "path";
import { OpenSecurityClient } from "./client.js";
import { Database } from "bun:sqlite";

const originalFetch = globalThis.fetch;
let child: ChildProcess | undefined;
let tempDir: string | undefined;

afterEach(() => {
globalThis.fetch = originalFetch;
child?.kill("SIGTERM");
child = undefined;
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
tempDir = undefined;
});

async function availablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") return reject(new Error("No TCP port allocated"));
server.close((error) => error ? reject(error) : resolve(address.port));
});
});
}

describe("OpenSecurityClient scan source boundary", () => {
test("omits sensitive-source opt-ins by default and forwards explicit choices", async () => {
const bodies: Array<Record<string, unknown>> = [];
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
bodies.push(JSON.parse(String(init?.body)));
return new Response(JSON.stringify({ id: "scan", scanner_types: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as unknown as typeof fetch;

const client = new OpenSecurityClient("http://127.0.0.1:1");
await client.triggerScan("/synthetic/repo");
await client.triggerScan("/synthetic/repo", {
include_git_history: true,
include_system: true,
});

expect(bodies[0]).toEqual({ path: "/synthetic/repo" });
expect(bodies[1]).toEqual({
path: "/synthetic/repo",
include_git_history: true,
include_system: true,
});
});

test("does not expose scanner-recognized values returned through the SDK", async () => {
globalThis.fetch = originalFetch;
tempDir = mkdtempSync(join(tmpdir(), "shield-sdk-boundary-"));
const synthetic = `gh${"r"}_${"A_".repeat(18)}`;
const projectDir = join(tempDir, synthetic);
mkdirSync(projectDir);
const port = await availablePort();
child = spawn("bun", ["run", "src/server/index.ts"], {
cwd: resolve(import.meta.dir, "../.."),
env: {
...process.env,
PORT: String(port),
HOME: tempDir,
USERPROFILE: tempDir,
SECURITY_DB: join(tempDir, "shield.db"),
CEREBRAS_API_KEY: "",
},
stdio: ["ignore", "pipe", "pipe"],
});
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("REST test server did not start")), 5_000);
child!.once("exit", (code) => {
clearTimeout(timeout);
reject(new Error(`REST test server exited early (${code})`));
});
child!.stdout!.on("data", (chunk) => {
if (String(chunk).includes("security dashboard")) {
clearTimeout(timeout);
resolve();
}
});
});

const client = new OpenSecurityClient(`http://127.0.0.1:${port}`);
const created = await client.createProject(`project-${synthetic}`, projectDir);
const listed = await client.listProjects();
for (const output of [JSON.stringify(created), JSON.stringify(listed)]) {
expect(output).not.toContain(synthetic);
expect(output).toContain("REDACTED");
}

const scan = await client.triggerScan(tempDir);
const db = new Database(join(tempDir, "shield.db"));
try {
db.prepare("UPDATE scans SET error = ? WHERE id = ?").run(synthetic, scan.id);
} finally {
db.close();
}
const fetchedScan = await client.getScan(scan.id);
const listedScans = await client.listScans();
for (const output of [JSON.stringify(fetchedScan), JSON.stringify(listedScans)]) {
expect(output).not.toContain(synthetic);
expect(output).toContain("REDACTED");
}
});
});
7 changes: 6 additions & 1 deletion sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ export class OpenSecurityClient {

async triggerScan(
path: string,
options?: { scanners?: string[]; llm_analyze?: boolean },
options?: {
scanners?: string[];
include_git_history?: boolean;
include_system?: boolean;
llm_analyze?: boolean;
},
): Promise<Scan> {
return this.request<Scan>("/api/scans", {
method: "POST",
Expand Down
2 changes: 1 addition & 1 deletion sdk/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
11 changes: 11 additions & 0 deletions src/cli/commands/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
explainFinding as llmExplainFinding,
suggestFix as llmSuggestFix,
} from "../../llm/index.js";
import { isCredentialFinding } from "../../lib/finding-safety.js";
import { getCodeContext } from "../helpers.js";

export function registerLLMCommands(program: Command): void {
Expand All @@ -27,6 +28,11 @@ export function registerLLMCommands(program: Command): void {
process.exit(1);
}

if (isCredentialFinding(finding)) {
console.error(chalk.red("\n LLM features are disabled for credential findings.\n"));
process.exit(1);
}

if (finding.llm_explanation) {
console.log(chalk.bold("\n Explanation (cached):\n"));
console.log(` ${finding.llm_explanation}\n`);
Expand Down Expand Up @@ -64,6 +70,11 @@ export function registerLLMCommands(program: Command): void {
process.exit(1);
}

if (isCredentialFinding(finding)) {
console.error(chalk.red("\n LLM features are disabled for credential findings.\n"));
process.exit(1);
}

if (finding.llm_fix) {
console.log(chalk.bold("\n Suggested Fix (cached):\n"));
console.log(finding.llm_fix);
Expand Down
72 changes: 72 additions & 0 deletions src/cli/commands/scan-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execFileSync, spawnSync } from "child_process";
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";

describe("CLI scan source and error boundaries", () => {
let tempDir: string;
let repoDir: string;
let env: NodeJS.ProcessEnv;

beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "shield-cli-boundary-"));
repoDir = join(tempDir, "repo");
execFileSync("mkdir", ["-p", repoDir]);
env = {
...process.env,
HOME: tempDir,
USERPROFILE: tempDir,
SECURITY_DB: join(tempDir, "shield.db"),
CEREBRAS_API_KEY: "",
};
});

afterEach(() => rmSync(tempDir, { recursive: true, force: true }));

test("ordinary scan omits history until the current command explicitly opts in", () => {
const syntheticSecret = "ghp_" + "SYNTHETICONLYABCDEFGHIJKLMNOPQRSTUVWXYZ12";
execFileSync("git", ["init", "-q"], { cwd: repoDir });
execFileSync("git", ["config", "user.email", "synthetic@example.invalid"], { cwd: repoDir });
execFileSync("git", ["config", "user.name", "Synthetic Test"], { cwd: repoDir });
writeFileSync(join(repoDir, "history.txt"), `TOKEN=${syntheticSecret}\n`, "utf-8");
execFileSync("git", ["add", "history.txt"], { cwd: repoDir });
execFileSync("git", ["commit", "-qm", "synthetic secret"], { cwd: repoDir });
writeFileSync(join(repoDir, "history.txt"), "safe=true\n", "utf-8");
execFileSync("git", ["add", "history.txt"], { cwd: repoDir });
execFileSync("git", ["commit", "-qm", "remove synthetic secret"], { cwd: repoDir });

const normal = spawnSync("bun", ["run", "src/cli/index.tsx", "scan", repoDir, "--format", "json"], {
cwd: process.cwd(), env, encoding: "utf-8",
});
expect(normal.status).toBe(0);
expect(normal.stderr).not.toContain("git-history");
expect(normal.stdout).not.toContain(syntheticSecret);

const optedIn = spawnSync("bun", ["run", "src/cli/index.tsx", "scan", repoDir, "--format", "json", "--git-history"], {
cwd: process.cwd(), env, encoding: "utf-8",
});
expect(optedIn.stderr).toContain("git-history");
expect(`${optedIn.stdout}${optedIn.stderr}`).not.toContain(syntheticSecret);
});

test("files-only command scans regular files and withholds failing paths", () => {
const syntheticSecret = "ghp_" + "SYNTHETICONLYABCDEFGHIJKLMNOPQRSTUVWXYZ12";
const file = join(tempDir, "synthetic.env");
writeFileSync(file, `TOKEN=${syntheticSecret}\n`, "utf-8");
const regular = spawnSync("bun", ["run", "src/cli/index.tsx", "secrets", file, "--files-only", "--json"], {
cwd: process.cwd(), env, encoding: "utf-8",
});
expect(regular.status).toBe(1);
expect(regular.stdout).toContain('"total"');
expect(regular.stdout).not.toContain(syntheticSecret);

const loop = join(tempDir, syntheticSecret);
symlinkSync(loop, loop);
const failed = spawnSync("bun", ["run", "src/cli/index.tsx", "secrets", loop, "--files-only", "--json"], {
cwd: process.cwd(), env, encoding: "utf-8",
});
expect(failed.status).toBe(1);
expect(`${failed.stdout}${failed.stderr}`).not.toContain(syntheticSecret);
});
});
Loading
Loading