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
78 changes: 78 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Argus security CI — shift-left gates for a tool that ships offensive capability.
# Hardening per OpenSSF post-tj-actions guidance: every action pinned to a full
# commit SHA (never a mutable tag), least-privilege GITHUB_TOKEN, no secrets.
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

defaults:
run:
working-directory: aegis

jobs:
test:
name: tests (pytest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
with:
python-version: "3.12"
cache: pip
cache-dependency-path: aegis/requirements.txt
- name: Install deps
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run test suite
run: python -m pytest -q

sast:
name: SAST (Bandit) — blocking
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
with:
python-version: "3.12"
- name: Install Bandit
run: pip install "bandit[toml]==1.8.6"
# Blocks the build on medium+ severity / medium+ confidence findings.
- name: Run Bandit (medium+)
run: bandit -c pyproject.toml -r aegis --severity-level medium --confidence-level medium

deps:
name: Dependency audit (pip-audit) — informational
runs-on: ubuntu-latest
# Informational until requirements are hash-pinned + triaged (see NEXT_STEPS.md
# P0.3). Flip to blocking once the dependency tree is locked.
continue-on-error: true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
with:
python-version: "3.12"
- name: Install pip-audit
run: pip install pip-audit==2.10.0
- name: Audit dependencies
run: pip-audit -r requirements.txt --desc

secrets:
name: Secret scan (detect-secrets) — informational
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
with:
python-version: "3.12"
- name: Install detect-secrets
run: pip install detect-secrets==1.5.0
- name: Scan working tree for secrets
run: detect-secrets scan aegis targets
Comment on lines +77 to +78

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 suggestion (security): The paths passed to detect-secrets depend on the workflow-level working directory and may not cover the whole repo as intended.

With defaults.run.working-directory set to aegis, detect-secrets scan aegis targets actually scans aegis/aegis and aegis/targets from the repo root, and skips top-level paths like .github or any non-aegis directories. If you want full-repo coverage, consider removing the workflow-level working-directory for this job or adjusting the command to scan from the repo root (e.g., detect-secrets scan . .. or explicitly listing root-level directories).

27 changes: 27 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Shift-left mirror of the CI gates (.github/workflows/ci.yml). Run locally with:
# pip install pre-commit && pre-commit install
# CI remains the authoritative gate (pre-commit can be skipped with --no-verify).
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-merge-conflict
- id: detect-private-key

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.4
hooks:
- id: ruff
args: [--fix]
files: ^aegis/

- repo: https://github.com/PyCQA/bandit
rev: 1.8.6
hooks:
- id: bandit
args: ["-c", "aegis/pyproject.toml", "--severity-level", "medium",
"--confidence-level", "medium"]
files: ^aegis/aegis/
2 changes: 1 addition & 1 deletion aegis/aegis/agent/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from dataclasses import dataclass, field

from ..tools import Observation, PROFILES
from ..tools import PROFILES, Observation

# Which follow-up profile each evidence signal suggests (read-only profiles only).
# signal-substring -> profile key
Expand Down
6 changes: 3 additions & 3 deletions aegis/aegis/ai_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def resolve_provider(override: str | None = None) -> str:
def list_ollama_models() -> list[str]:
"""Installed local models via GET /api/tags (empty list if Ollama is down)."""
try:
with urllib.request.urlopen(f"{OLLAMA_HOST}/api/tags", timeout=3) as resp: # noqa: S310
with urllib.request.urlopen(f"{OLLAMA_HOST}/api/tags", timeout=3) as resp: # nosec B310 fixed http scheme # noqa: S310
data = json.loads(resp.read())
return sorted(m.get("name", "") for m in data.get("models", []) if m.get("name"))
except Exception: # noqa: BLE001
Expand Down Expand Up @@ -184,10 +184,10 @@ def _llm_complete(system: str, user: str, model: str, *, budget=None,
"messages": [{"role": "system", "content": system},
{"role": "user", "content": user}],
}).encode()
req = urllib.request.Request(
req = urllib.request.Request( # noqa: S310
f"{OLLAMA_HOST}/api/chat", data=payload,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as resp: # noqa: S310 (local host)
with urllib.request.urlopen(req, timeout=120) as resp: # nosec B310 fixed http scheme # noqa: S310
data = json.loads(resp.read())
if budget:
tok = data.get("prompt_eval_count", 0) + data.get("eval_count", 0)
Expand Down
7 changes: 6 additions & 1 deletion aegis/aegis/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,12 @@ def cmd_host(args) -> int:
if _refuse_if_out_of_scope(guard, args.target):
return 2
if args.os == "windows":
from .host.winrm_collector import DryRunWinRM, WinHostOrchestrator, WinRMCollector, WinRMCreds
from .host.winrm_collector import (
DryRunWinRM,
WinHostOrchestrator,
WinRMCollector,
WinRMCreds,
)
creds = WinRMCreds(user=args.user, password=args.password or "",
https=not args.winrm_http, verify_cert=not args.winrm_insecure)
if args.fixture:
Expand Down
2 changes: 1 addition & 1 deletion aegis/aegis/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class Policy:
redact_patterns: tuple[str, ...] = field(default=())

@staticmethod
def load(path: Path | str = DEFAULT_POLICY) -> "Policy":
def load(path: Path | str = DEFAULT_POLICY) -> Policy:
data = yaml.safe_load(Path(path).read_text())
scope = data.get("scope", {})
fw = data.get("tool_firewall", {})
Expand Down
8 changes: 5 additions & 3 deletions aegis/aegis/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@ def run(self, plan: list[ScanStep]) -> ScanResult:
ex = self.sandbox.run(argv, timeout=timeout)
self.guard.record(tool.binary, exit_code=ex.exit_code,
summary=self.guard.sanitize(ex.stdout[:300]))
# Surface a missing binary (shell exit 127 = command not found) so a tool
# never silently no-ops. (Don't match 'not found' in output — tools like
# Surface a missing binary so a tool never silently no-ops. The sandbox
# sets tool_missing only when the binary was never launched — a tool that
# ran and exited 127 is a real failure, not a missing tool, and is left to
# parse/observe normally. (Don't match 'not found' in output — tools like
# snmpwalk legitimately print that for empty results.)
if ex.exit_code == 127:
if ex.tool_missing:
result.errors.append(f"{step.tool_key}: tool unavailable in sandbox (not installed)")
obs = tool.parse(self.guard.sanitize(ex.stdout), step.target)
result.observations.extend(obs)
Expand Down
8 changes: 4 additions & 4 deletions aegis/aegis/recon/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import urllib.request
from dataclasses import dataclass
from typing import Callable, Protocol
from typing import Protocol

from ..tools import Observation

Expand Down Expand Up @@ -79,17 +79,17 @@ def __init__(self, max_body: int = 4096):
self.max_body = max_body

def get(self, url: str, timeout: int) -> HttpResult:
req = urllib.request.Request(url, method="GET",
req = urllib.request.Request(url, method="GET", # noqa: S310
headers={"User-Agent": "Argus-Recon/1.0"})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310 scheme validated by scope guard # noqa: S310
body = resp.read(self.max_body).decode("utf-8", "replace")
return HttpResult(resp.status, body, resp.headers.get("Server", ""))
except urllib.error.HTTPError as exc: # 401/403/404 still informative
body = b""
try:
body = exc.read(self.max_body)
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001, S110 - error body is best-effort
pass
return HttpResult(exc.code, body.decode("utf-8", "replace"),
exc.headers.get("Server", "") if exc.headers else "")
Expand Down
2 changes: 0 additions & 2 deletions aegis/aegis/reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
from datetime import date
from pathlib import Path

from .ai_analyzer import Finding


def collapse_errors(errors: list[str]) -> list[str]:
"""Collapse repeated denials across targets into one summarized line each.
Expand Down
22 changes: 16 additions & 6 deletions aegis/aegis/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ class ExecResult:
stdout: str
stderr: str
timed_out: bool = False
# True only when the sandbox KNOWS the binary was never launched (missing on
# PATH / empty argv). A tool that actually ran and exited 127 leaves this False,
# so the orchestrator can stop mislabeling a real failure as "not installed".
tool_missing: bool = False


def _exec_argv(argv: list[str], timeout: int,
Expand All @@ -41,10 +45,10 @@ def _exec_argv(argv: list[str], timeout: int,
scrubbed allowlist.
"""
try:
p = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, env=env, start_new_session=True) # noqa: S603 (argv, no shell)
p = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, # noqa: S603
text=True, env=env, start_new_session=True) # argv array, never shell=True
except FileNotFoundError as exc:
return ExecResult(127, "", str(exc))
return ExecResult(127, "", str(exc), tool_missing=True)
try:
out, err = p.communicate(timeout=timeout)
return ExecResult(p.returncode, out, err)
Expand All @@ -68,7 +72,12 @@ def __init__(self, compose_file: Path | str, service: str = "attacker"):
def run(self, argv: list[str], timeout: int) -> ExecResult:
cmd = ["docker", "compose", "-f", self.compose_file, "exec", "-T",
self.service, *argv]
return _exec_argv(cmd, timeout)
res = _exec_argv(cmd, timeout)
# `docker exec` returns 127 for a missing in-container binary; we can't tell
# that apart from a tool that ran and exited 127, so keep the heuristic here.
if res.exit_code == 127 and not res.timed_out:
res.tool_missing = True
return res


# Minimal environment for host-spawned tools. This is an ALLOWLIST, not a
Expand Down Expand Up @@ -110,9 +119,10 @@ def _safe_env(self) -> dict[str, str]:

def run(self, argv: list[str], timeout: int) -> ExecResult:
if not argv:
return ExecResult(127, "", "empty argv")
return ExecResult(127, "", "empty argv", tool_missing=True)
if not shutil.which(argv[0]):
return ExecResult(127, "", f"{argv[0]}: not installed on host")
return ExecResult(127, "", f"{argv[0]}: not installed on host", tool_missing=True)
# A real run that exits 127 keeps tool_missing=False — it is NOT a missing tool.
return _exec_argv(argv, timeout, env=self._safe_env())


Expand Down
4 changes: 2 additions & 2 deletions aegis/aegis/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@

import json
import re
from collections.abc import Callable
from dataclasses import dataclass
from typing import Callable

# defusedxml hardens against XXE / billion-laughs — tool output is UNTRUSTED.
try:
Expand Down Expand Up @@ -50,7 +50,7 @@ class Tool:
def _nmap_xml(text: str, target: str) -> list[Observation]:
obs: list[Observation] = []
try:
root = ET.fromstring(text)
root = ET.fromstring(text) # nosec B314 ET is defusedxml (see import) # noqa: S314
except ParseError:
return obs
for host in root.findall("host"):
Expand Down
2 changes: 1 addition & 1 deletion aegis/aegis/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class HostRequest(BaseModel):
target: str
os: str = "linux" # linux | windows
user: str = "pentest"
password: str = "pentest"
password: str = "pentest" # noqa: S105 - lab default cred, overridden by operator
profile: str = "host-linux"
dry_run: bool = False # windows dry-run when no live host
provider: str | None = None
Expand Down
Loading
Loading