Skip to content

Commit 3e8dd88

Browse files
author
Greyforge Admin
committed
Harden devcap scanner boundaries
1 parent 3ea92f1 commit 3e8dd88

15 files changed

Lines changed: 735 additions & 84 deletions

.github/workflows/ci.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,19 @@ on:
66
pull_request:
77
branches: [main]
88

9+
permissions:
10+
contents: read
11+
912
jobs:
1013
test:
1114
runs-on: ubuntu-latest
15+
timeout-minutes: 10
1216
strategy:
1317
matrix:
1418
python-version: ["3.11", "3.12"]
1519
steps:
16-
- uses: actions/checkout@v6
17-
- uses: actions/setup-python@v6
20+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
21+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
1822
with:
1923
python-version: ${{ matrix.python-version }}
2024
- run: python -m pip install --upgrade pip

.github/workflows/publish-pypi.yml

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ on:
66
ref:
77
description: "Git ref to publish. Use a release tag such as v0.1.0."
88
required: true
9-
default: "main"
9+
default: "v0.1.0"
1010

1111
permissions:
1212
contents: read
@@ -15,20 +15,56 @@ permissions:
1515
jobs:
1616
publish:
1717
runs-on: ubuntu-latest
18+
timeout-minutes: 15
1819
environment:
1920
name: pypi
2021
url: https://pypi.org/p/devcap
2122
steps:
22-
- uses: actions/checkout@v6
23+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
2324
with:
2425
ref: ${{ inputs.ref }}
25-
- uses: actions/setup-python@v6
26+
fetch-depth: 0
27+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
2628
with:
2729
python-version: "3.12"
30+
- name: Validate publish tag matches project version
31+
env:
32+
PUBLISH_REF: ${{ inputs.ref }}
33+
run: |
34+
set -euo pipefail
35+
36+
case "$PUBLISH_REF" in
37+
v[0-9]*.[0-9]*.[0-9]*) ;;
38+
*)
39+
echo "Publish ref must be a version tag like v0.1.0"
40+
exit 1
41+
;;
42+
esac
43+
44+
TAG_VERSION="${PUBLISH_REF#v}"
45+
PROJECT_VERSION="$(python - <<'PY'
46+
import tomllib
47+
with open("pyproject.toml", "rb") as handle:
48+
data = tomllib.load(handle)
49+
print(data["project"]["version"])
50+
PY
51+
)"
52+
53+
if [ "$TAG_VERSION" != "$PROJECT_VERSION" ]; then
54+
echo "Publish tag $TAG_VERSION does not match project.version $PROJECT_VERSION"
55+
exit 1
56+
fi
57+
58+
HEAD_SHA="$(git rev-parse HEAD)"
59+
TAG_SHA="$(git rev-list -n 1 "$PUBLISH_REF")"
60+
if [ "$HEAD_SHA" != "$TAG_SHA" ]; then
61+
echo "Checked-out commit does not match tag $PUBLISH_REF"
62+
exit 1
63+
fi
2864
- run: python -m pip install --upgrade pip
2965
- run: python -m pip install -e ".[dev]"
3066
- run: python -m ruff check .
3167
- run: python -m pytest
3268
- run: python -m build
3369
- run: python -m twine check dist/*
34-
- uses: pypa/gh-action-pypi-publish@release/v1
70+
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1

.github/workflows/release.yml

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,21 @@ on:
66
- "v*"
77

88
permissions:
9-
contents: write
9+
contents: read
1010

1111
jobs:
1212
build:
1313
runs-on: ubuntu-latest
14+
timeout-minutes: 15
1415
steps:
15-
- uses: actions/checkout@v6
16-
- uses: actions/setup-python@v6
16+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
17+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
1718
with:
1819
python-version: "3.12"
1920
- name: Validate tag matches project version
2021
run: |
22+
set -euo pipefail
23+
2124
TAG_VERSION="${GITHUB_REF_NAME#v}"
2225
PROJECT_VERSION="$(python - <<'PY'
2326
import tomllib
@@ -37,20 +40,23 @@ jobs:
3740
- run: python -m pytest
3841
- run: python -m build
3942
- run: python -m twine check dist/*
40-
- uses: actions/upload-artifact@v8
43+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
4144
with:
4245
name: dist
4346
path: dist/*
4447

4548
github-release:
4649
needs: build
4750
runs-on: ubuntu-latest
51+
timeout-minutes: 10
52+
permissions:
53+
contents: write
4854
steps:
49-
- uses: actions/download-artifact@v8
55+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
5056
with:
5157
name: dist
5258
path: dist
53-
- uses: softprops/action-gh-release@v2
59+
- uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
5460
with:
5561
files: dist/*
5662
generate_release_notes: true

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
66

7+
## [Unreleased]
8+
9+
### Security
10+
11+
- Validate custom TOML profile schema, command fields, service names, and profile size before scanning.
12+
- Reject high-risk custom interpreter commands and shell-control characters in custom version flags.
13+
- Skip vendored/project-local PATH segments by default; add `--include-vendored` for trusted checkouts.
14+
- Add `--redact` to replace hostnames and executable paths before public sharing.
15+
- Sanitize terminal control sequences and Markdown table delimiters in human-readable output.
16+
- Add `systemctl --` argument separation for service checks and process-group cleanup on scan timeouts.
17+
- Harden GitHub Actions release/publish workflows with pinned action commits, tag/version checks, job timeouts, and narrower permissions.
18+
719
## [0.1.0] - 2026-04-06
820

921
### Added

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ devcap scan --format json
3636
# Markdown tables (paste into docs)
3737
devcap scan --format markdown
3838

39+
# Public-safe metadata: suppress hostname and executable paths
40+
devcap scan --format markdown --redact
41+
3942
# Scan only Python-related tools
4043
devcap scan --profile python-dev
4144

@@ -45,6 +48,9 @@ devcap check --profile devops
4548
# Custom profile
4649
devcap scan --config my-tools.toml
4750

51+
# Include project-local/vendor PATH entries such as node_modules/.bin or .venv/bin
52+
devcap scan --profile node-dev --include-vendored
53+
4854
# List available profiles
4955
devcap list-profiles
5056
```
@@ -91,6 +97,8 @@ version_flag = "-v"
9197

9298
Tools listed in the registry inherit their detection config automatically. Custom tools need `binary` and optionally `version_flag`.
9399

100+
Custom profiles execute local binaries to collect versions. Treat profiles from third-party repositories like code, not passive data. By default, `devcap` rejects interpreter-style custom commands and skips vendored/project-local PATH entries such as `node_modules`, `.venv`, `venv`, `__pypackages__`, `.tox`, and `.nox`; use `--include-vendored` only when you trust the checkout being scanned.
101+
94102
## Output Formats
95103

96104
**Text** (default) — columnar, human-readable:
@@ -113,7 +121,7 @@ Tools listed in the registry inherit their detection config automatically. Custo
113121
}
114122
```
115123

116-
**Markdown** — tables for documentation or READMEs.
124+
**Markdown** — tables for documentation or READMEs. Terminal control sequences and Markdown table delimiters from tool output are sanitized before display, but environment inventory can still reveal hostnames, paths, installed tools, and service status. Use `--redact` to replace hostname and executable paths before publishing output.
117125

118126
## Exit Codes
119127

SECURITY.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ Instead, use one of these methods:
2424
- Potential impact
2525
- Suggested fix (if you have one)
2626

27+
## Local Scan Boundary
28+
29+
`devcap` is a local inventory tool. It executes discovered binaries with version flags, so custom profiles and PATH entries from untrusted repositories must be treated as executable inputs. The default scanner rejects high-risk custom interpreter commands, skips vendored/project-local PATH segments unless `--include-vendored` is set, validates custom profile schema, separates `systemctl` options from service names, and sanitizes terminal/Markdown display output.
30+
31+
Inventory output may include hostnames, OS details, executable paths, tool versions, and service state. Use `--redact` to suppress hostname and executable paths, then review JSON, text, and Markdown output before publishing it or uploading it as a public artifact.
32+
2733
## Response Timeline
2834

2935
- **Acknowledgment**: Within 48 hours

src/devcap/cli.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from .formatters import FORMATTERS
99
from .profile_loader import list_builtin_profiles, load_builtin_profile, load_custom_profile
10-
from .scanner import scan_tools
10+
from .scanner import redact_scan, scan_tools
1111

1212

1313
def main(argv: list[str] | None = None) -> int:
@@ -72,6 +72,19 @@ def _add_scan_arguments(parser: argparse.ArgumentParser) -> None:
7272
action="store_true",
7373
help="Disable parallel scanning (useful for debugging)",
7474
)
75+
parser.add_argument(
76+
"--include-vendored",
77+
action="store_true",
78+
help=(
79+
"Allow executables from vendored/project-local PATH segments such as "
80+
"node_modules or .venv"
81+
),
82+
)
83+
parser.add_argument(
84+
"--redact",
85+
action="store_true",
86+
help="Replace hostname and executable paths with [redacted] in output",
87+
)
7588

7689

7790
def _cmd_list_profiles() -> int:
@@ -86,24 +99,33 @@ def _cmd_list_profiles() -> int:
8699

87100
def _cmd_scan(args: argparse.Namespace) -> int:
88101
# Load profile
89-
if args.config:
90-
profile = load_custom_profile(args.config)
91-
elif args.profile:
92-
try:
102+
try:
103+
if args.config:
104+
profile = load_custom_profile(args.config)
105+
elif args.profile:
93106
profile = load_builtin_profile(args.profile)
94-
except FileNotFoundError:
107+
else:
108+
profile = load_builtin_profile("full")
109+
except FileNotFoundError:
110+
if args.profile:
95111
print(f"Error: unknown profile '{args.profile}'", file=sys.stderr)
96112
print(f"Available: {', '.join(list_builtin_profiles())}", file=sys.stderr)
97-
return 2
98-
else:
99-
profile = load_builtin_profile("full")
113+
else:
114+
print(f"Error: profile not found: {args.config}", file=sys.stderr)
115+
return 2
116+
except ValueError as exc:
117+
print(f"Error: invalid profile: {exc}", file=sys.stderr)
118+
return 2
100119

101120
# Scan
102121
result = scan_tools(
103122
tools=profile.tools,
104123
services=profile.services,
105124
parallel=not args.no_parallel,
125+
include_vendored=args.include_vendored,
106126
)
127+
if args.redact:
128+
result = redact_scan(result)
107129

108130
# Format and print
109131
formatter = FORMATTERS[args.format]

src/devcap/formatters.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66

77
from .registry import CATEGORIES
8+
from .safe_text import clean_text, markdown_cell, markdown_inline_code
89
from .scanner import ScanResult, ToolResult
910

1011

@@ -25,9 +26,11 @@ def _group_by_category(results: list[ToolResult]) -> dict[str, list[ToolResult]]
2526

2627
def format_text(scan: ScanResult) -> str:
2728
"""Format scan results as human-readable columnar text."""
29+
hostname = clean_text(scan.hostname, max_length=120)
30+
timestamp = clean_text(scan.timestamp, max_length=80)
2831
lines = [
29-
f"devcap scan — {scan.hostname}{scan.timestamp}",
30-
f"Platform: {scan.platform}",
32+
f"devcap scan — {hostname}{timestamp}",
33+
f"Platform: {clean_text(scan.platform, max_length=160)}",
3134
"",
3235
]
3336

@@ -36,23 +39,25 @@ def format_text(scan: ScanResult) -> str:
3639
found = [t for t in tools if t.found]
3740
missing = [t for t in tools if not t.found]
3841

39-
lines.append(f"=== {category} ===")
42+
lines.append(f"=== {clean_text(category, max_length=80)} ===")
4043
if found:
4144
for t in found:
42-
version = t.version or "?"
43-
lines.append(f" {t.name:<16} {version:<20} {t.path}")
45+
name = clean_text(t.name, max_length=32)
46+
version = clean_text(t.version or "?", max_length=80)
47+
path = clean_text(t.path or "", max_length=240)
48+
lines.append(f" {name:<16} {version:<20} {path}")
4449
if missing:
4550
lines.append(" Missing:")
4651
for t in missing:
47-
lines.append(f" {t.name}")
52+
lines.append(f" {clean_text(t.name, max_length=80)}")
4853
lines.append("")
4954

5055
if scan.services:
5156
lines.append("=== Services ===")
5257
for svc in scan.services:
5358
status = "running" if svc.active else "stopped"
5459
suffix = " (user)" if svc.user_service else ""
55-
lines.append(f" [{status}] {svc.name}{suffix}")
60+
lines.append(f" [{status}] {clean_text(svc.name, max_length=120)}{suffix}")
5661
lines.append("")
5762

5863
found_count = sum(1 for r in scan.results if r.found)
@@ -69,10 +74,12 @@ def format_json(scan: ScanResult) -> str:
6974

7075
def format_markdown(scan: ScanResult) -> str:
7176
"""Format scan results as markdown tables."""
77+
timestamp = markdown_cell(scan.timestamp, max_length=80)
78+
platform = markdown_cell(scan.platform, max_length=160)
7279
lines = [
73-
f"# Development Environment — {scan.hostname}",
80+
f"# Development Environment — {markdown_cell(scan.hostname, max_length=120)}",
7481
"",
75-
f"> Scanned: {scan.timestamp} | Platform: {scan.platform}",
82+
f"> Scanned: {timestamp} | Platform: {platform}",
7683
"",
7784
]
7885

@@ -81,17 +88,19 @@ def format_markdown(scan: ScanResult) -> str:
8188
found = [t for t in tools if t.found]
8289
missing = [t for t in tools if not t.found]
8390

84-
lines.append(f"## {category}")
91+
lines.append(f"## {markdown_cell(category, max_length=80)}")
8592
lines.append("")
8693
if found:
8794
lines.append("| Tool | Version | Path |")
8895
lines.append("|------|---------|------|")
8996
for t in found:
90-
version = t.version or "?"
91-
lines.append(f"| {t.name} | {version} | {t.path} |")
97+
name = markdown_cell(t.name, max_length=80)
98+
version = markdown_cell(t.version or "?", max_length=80)
99+
path = markdown_cell(t.path or "")
100+
lines.append(f"| {name} | {version} | {path} |")
92101
lines.append("")
93102
if missing:
94-
missing_names = ", ".join(f"`{t.name}`" for t in missing)
103+
missing_names = ", ".join(markdown_inline_code(t.name) for t in missing)
95104
lines.append(f"**Not installed**: {missing_names}")
96105
lines.append("")
97106

@@ -103,7 +112,7 @@ def format_markdown(scan: ScanResult) -> str:
103112
for svc in scan.services:
104113
status = "running" if svc.active else "stopped"
105114
suffix = " (user)" if svc.user_service else ""
106-
lines.append(f"| {svc.name}{suffix} | {status} |")
115+
lines.append(f"| {markdown_cell(svc.name, max_length=120)}{suffix} | {status} |")
107116
lines.append("")
108117

109118
return "\n".join(lines)

0 commit comments

Comments
 (0)