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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ format and uses semantic versioning when versioned releases are published.

## [Unreleased]

### Fixed

- Join shell continuations and recognize leading environment assignments when
discovering commands in Markdown fences.

### Added

- Added verified npm trusted publication with provenance and reuse of the packed artifact in GitHub releases.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ For a fixture-backed walkthrough, see [`docs/tutorials/audit-agent-cli-scripts.m
- `package.json` scripts across the repository.
- `pnpm-workspace.yaml` workspace hints.
- Makefile targets.
- Markdown shell blocks fenced with backticks or tildes and labelled `bash`, `sh`, `shell`, `console`, or `zsh` (unlabelled fences are also scanned). Each independently executable line must begin with a supported command or an optional `$` prompt. Supported commands include package and task runners, Node and shell entry points, plus risk-relevant network, container, version-control, destructive, permission, deploy, and publish tools such as `curl`, `docker`, `git`, `rm`, `sudo`, and `vercel`.
- Markdown shell blocks fenced with backticks or tildes and labelled `bash`, `sh`, `shell`, `console`, or `zsh` (unlabelled fences are also scanned). Shell lines ending in `\` are joined before classification, and commands may begin with standard environment assignments such as `CI=1 npm test`. Each independently executable command must otherwise begin with a supported command or an optional `$` prompt. Supported commands include package and task runners, Node and shell entry points, plus risk-relevant network, container, version-control, destructive, permission, deploy, and publish tools such as `curl`, `docker`, `git`, `rm`, `sudo`, and `vercel`.
- Justfile recipes and Taskfile `cmds` entries written as scalar commands (`- npm test`) or inline mappings (`- cmd: npm test`).

Make discovery recognizes ordinary named targets, including rules that list
Expand Down
6 changes: 6 additions & 0 deletions examples/fixtures/docs-only/docs/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,9 @@ docker compose up
sudo chmod 600 .env
npm test
```

```bash
rm \
-rf ./generated
CI=1 npm test
```
15 changes: 10 additions & 5 deletions src/discover/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import path from "node:path";
import { findFilesByExtension, readText, toPosixRelative } from "../files.js";
import type { CommandSource } from "../types.js";

const COMMAND_PREFIX = /^(?:\$\s*)?(?:npm|pnpm|yarn|node|npx|bash|sh|make|just|task|deno|bun|tsx|curl|wget|docker(?:-compose)?|git|gh|rm|sudo|chmod|chown|vercel|flyctl|netlify|changeset)\b/;
const COMMAND_PREFIX = /^(?:(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]+))\s+)*(?:npm|pnpm|yarn|node|npx|bash|sh|make|just|task|deno|bun|tsx|curl|wget|docker(?:-compose)?|git|gh|rm|sudo|chmod|chown|vercel|flyctl|netlify|changeset)\b/;

export async function discoverMarkdownCommands(root: string): Promise<CommandSource[]> {
const files = await findFilesByExtension(root, ".md");
Expand Down Expand Up @@ -49,20 +49,25 @@ function extractCodeBlockCommands(relativeFile: string, text: string): CommandSo
continue;
}

const command = line.trim().replace(/^\$\s*/, "");
const sourceLine = index + 1;
let command = line.trim().replace(/^\$\s*/, "");
while (/\\$/.test(command) && index + 1 < lines.length) {
command = `${command.slice(0, -1).trimEnd()} ${lines[index + 1].trim()}`;
index += 1;
}
if (!COMMAND_PREFIX.test(command)) {
continue;
}

const basename = path.basename(relativeFile, ".md").toLowerCase();
commands.push({
id: `${relativeFile}#code-${index + 1}`,
name: `${basename}:line-${index + 1}`,
id: `${relativeFile}#code-${sourceLine}`,
name: `${basename}:line-${sourceLine}`,
command,
kind: "markdown",
location: {
file: relativeFile,
line: index + 1
line: sourceLine
}
});
}
Expand Down
11 changes: 11 additions & 0 deletions tests/scan.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ test("scans risk-relevant commands in shell documentation", async () => {
assert.equal(commands.get("npm test")?.risk, "safe");
});

test("joins shell continuations and recognizes environment assignments", async () => {
const report = await scanProject({ root: fixture("docs-only") });
const commands = new Map(report.commands.map((command) => [command.command, command]));

assert.equal(commands.get("rm -rf ./generated")?.risk, "dangerous");
assert.equal(commands.get("rm -rf ./generated")?.location.line, 24);
assert.equal(commands.get("CI=1 npm test")?.risk, "safe");
assert.equal(commands.get("CI=1 npm test")?.location.line, 26);
assert.equal(commands.has("rm \\"), false);
});

test("scans only executable Taskfile commands", async () => {
const report = await scanProject({ root: fixture("taskfile-metadata") });
const taskCommands = report.commands.filter((command) => command.kind === "taskfile");
Expand Down