argus-security 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- argus/__init__.py +3 -0
- argus/__main__.py +6 -0
- argus/architecture_map.py +105 -0
- argus/audit/__init__.py +16 -0
- argus/audit/logger.py +184 -0
- argus/audit/manifest.py +202 -0
- argus/audit/platform.py +108 -0
- argus/audit/secrets.py +90 -0
- argus/cli.py +3372 -0
- argus/collect/__init__.py +10 -0
- argus/collect/collector.py +177 -0
- argus/collect/merger.py +237 -0
- argus/container/__init__.py +23 -0
- argus/container/builder.py +56 -0
- argus/container/discovery.py +281 -0
- argus/container/engine.py +283 -0
- argus/container/resources.py +166 -0
- argus/container/scanner.py +687 -0
- argus/container_runtime.py +148 -0
- argus/containers.py +149 -0
- argus/core/__init__.py +19 -0
- argus/core/config.py +299 -0
- argus/core/engine.py +1453 -0
- argus/core/exclusions.py +226 -0
- argus/core/findings_view.py +360 -0
- argus/core/image_verify.py +252 -0
- argus/core/linter_template.py +380 -0
- argus/core/models.py +371 -0
- argus/core/prewarm.py +216 -0
- argus/core/redact.py +216 -0
- argus/core/sbom.py +258 -0
- argus/core/scanner.py +48 -0
- argus/core/scanner_template.py +245 -0
- argus/core/schema.py +745 -0
- argus/core/secrets.py +182 -0
- argus/core/tool_config.py +177 -0
- argus/core/version.py +66 -0
- argus/dast/__init__.py +16 -0
- argus/dast/engine.py +458 -0
- argus/dast/inspect.py +145 -0
- argus/dast/runner.py +350 -0
- argus/init.py +508 -0
- argus/linters/__init__.py +54 -0
- argus/linters/eslint.py +235 -0
- argus/linters/hadolint.py +101 -0
- argus/linters/jsonlint.py +103 -0
- argus/linters/python_lint.py +116 -0
- argus/linters/terraform.py +202 -0
- argus/linters/yamllint.py +242 -0
- argus/mcp.py +1067 -0
- argus/preflight/__init__.py +1 -0
- argus/preflight/ci_provider.py +77 -0
- argus/preflight/issue_reporter.py +227 -0
- argus/preflight/network_deps.py +36 -0
- argus/preflight/report_body.py +101 -0
- argus/preflight/tool_check.py +98 -0
- argus/reporters/__init__.py +312 -0
- argus/reporters/container_markdown.py +450 -0
- argus/reporters/github.py +131 -0
- argus/reporters/gitlab.py +155 -0
- argus/reporters/json_report.py +30 -0
- argus/reporters/junit.py +211 -0
- argus/reporters/markdown.py +181 -0
- argus/reporters/sarif.py +118 -0
- argus/reporters/terminal.py +165 -0
- argus/scanners/__init__.py +92 -0
- argus/scanners/_vuln_parsers.py +71 -0
- argus/scanners/bandit.py +133 -0
- argus/scanners/checkov.py +135 -0
- argus/scanners/clamav.py +131 -0
- argus/scanners/container.py +971 -0
- argus/scanners/gitleaks.py +128 -0
- argus/scanners/grype.py +146 -0
- argus/scanners/opengrep.py +95 -0
- argus/scanners/osv.py +188 -0
- argus/scanners/supply_chain.py +325 -0
- argus/scanners/trivy.py +116 -0
- argus/scanners/trivy_iac.py +137 -0
- argus/scanners/zap.py +345 -0
- argus/scn/__init__.py +4 -0
- argus/scn/ai.py +369 -0
- argus/scn/classifier.py +284 -0
- argus/scn/config.py +350 -0
- argus/scn/defaults.py +143 -0
- argus/scn/diff.py +503 -0
- argus/scn/report.py +375 -0
- argus/scn/schemas/__init__.py +27 -0
- argus/update_check.py +266 -0
- argus/viewers/__init__.py +34 -0
- argus/viewers/browser/__init__.py +80 -0
- argus/viewers/browser/app.py +1061 -0
- argus/viewers/browser/log_view.py +177 -0
- argus/viewers/browser/static/architecture.css +1355 -0
- argus/viewers/browser/static/architecture.js +1494 -0
- argus/viewers/browser/static/argus.css +1021 -0
- argus/viewers/browser/static/auto-filter.js +109 -0
- argus/viewers/browser/static/export-copy.js +98 -0
- argus/viewers/browser/static/favicon.png +0 -0
- argus/viewers/browser/static/picker-compare.js +76 -0
- argus/viewers/browser/static/scan-mtime.js +35 -0
- argus/viewers/browser/static/theme-toggle.js +84 -0
- argus/viewers/browser/templates/_findings_table.html.j2 +144 -0
- argus/viewers/browser/templates/base.html.j2 +99 -0
- argus/viewers/browser/templates/diff.html.j2 +120 -0
- argus/viewers/browser/templates/findings.html.j2 +118 -0
- argus/viewers/browser/templates/log.html.j2 +68 -0
- argus/viewers/browser/templates/picker.html.j2 +129 -0
- argus/viewers/browser/templates/summary.html.j2 +216 -0
- argus/viewers/diagnose.py +150 -0
- argus/viewers/terminal/__init__.py +47 -0
- argus/viewers/terminal/app.py +2434 -0
- argus/viewers/terminal/clipboard.py +151 -0
- argus/viewers/terminal/export.py +288 -0
- argus/viewers/terminal/loader.py +71 -0
- argus/viewers/terminal/mouse_actions.py +537 -0
- argus-config.schema.json +298 -0
- argus_security-1.0.1.dist-info/METADATA +825 -0
- argus_security-1.0.1.dist-info/RECORD +123 -0
- argus_security-1.0.1.dist-info/WHEEL +5 -0
- argus_security-1.0.1.dist-info/entry_points.txt +12 -0
- argus_security-1.0.1.dist-info/licenses/LICENSE.md +673 -0
- argus_security-1.0.1.dist-info/top_level.txt +1 -0
- img/argus_logo.txt +30 -0
argus/__init__.py
ADDED
argus/__main__.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Runtime shim for the architecture-map view-model transformer.
|
|
2
|
+
|
|
3
|
+
The canonical implementation lives at ``scripts/docsite/architecture.py``
|
|
4
|
+
because the transformer follows the docsite-tooling patterns (see the
|
|
5
|
+
``scripts/docsite/builder.py`` / ``scripts/docsite/parsers.py`` style).
|
|
6
|
+
``scripts/docsite/`` isn't a Python package distributed in the
|
|
7
|
+
``argus-security`` wheel, though, so the runtime consumers (the
|
|
8
|
+
FastAPI ``/architecture`` route and the MCP ``argus://architecture``
|
|
9
|
+
resource) can't import it the normal way.
|
|
10
|
+
|
|
11
|
+
This shim resolves that by locating the file on disk relative to the
|
|
12
|
+
installed argus package and loading it via ``importlib.util``. In
|
|
13
|
+
editable-install workflows (``pip install -e .``) it just works.
|
|
14
|
+
In a clean wheel install with no source checkout, the load fails
|
|
15
|
+
fast and the caller surfaces an actionable error message rather than
|
|
16
|
+
a confusing ``ModuleNotFoundError`` deep in the stack.
|
|
17
|
+
|
|
18
|
+
Public surface is the same as the canonical module — anything
|
|
19
|
+
exported there is re-exported here. Update the ``_EXPORTS`` list if
|
|
20
|
+
the canonical surface grows.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import importlib.util
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Re-export list — keep in sync with scripts/docsite/architecture.py.
|
|
32
|
+
_EXPORTS = (
|
|
33
|
+
"SCHEMA_VERSION",
|
|
34
|
+
"Registries",
|
|
35
|
+
"introspect_registries",
|
|
36
|
+
"load_inputs",
|
|
37
|
+
"build_view_model",
|
|
38
|
+
"build_view_model_from_repo",
|
|
39
|
+
"COLUMNS",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _find_canonical() -> Path | None:
|
|
44
|
+
"""Locate ``scripts/docsite/architecture.py`` on disk.
|
|
45
|
+
|
|
46
|
+
Walks up from this file (the argus package) looking for the
|
|
47
|
+
docsite scripts directory. Editable installs put the argus
|
|
48
|
+
package next to the ``scripts/`` directory; wheel installs
|
|
49
|
+
don't, but those are out of scope for this resolver.
|
|
50
|
+
"""
|
|
51
|
+
here = Path(__file__).resolve()
|
|
52
|
+
for parent in here.parents:
|
|
53
|
+
candidate = parent / "scripts" / "docsite" / "architecture.py"
|
|
54
|
+
if candidate.is_file():
|
|
55
|
+
return candidate
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_canonical() -> Any:
|
|
60
|
+
"""Import the canonical module via importlib.util.
|
|
61
|
+
|
|
62
|
+
Returns the module object. Raises ``ImportError`` with an
|
|
63
|
+
actionable message when the file can't be found — typical cause
|
|
64
|
+
is a wheel install without a repo checkout for the SDK
|
|
65
|
+
introspection inputs.
|
|
66
|
+
"""
|
|
67
|
+
cached = sys.modules.get("argus._architecture_canonical")
|
|
68
|
+
if cached is not None:
|
|
69
|
+
return cached
|
|
70
|
+
path = _find_canonical()
|
|
71
|
+
if path is None:
|
|
72
|
+
raise ImportError(
|
|
73
|
+
"Architecture-map transformer not found. Expected "
|
|
74
|
+
"``scripts/docsite/architecture.py`` next to the argus "
|
|
75
|
+
"package. Install argus in editable mode "
|
|
76
|
+
"(``pip install -e .`` from the argus repo root) or "
|
|
77
|
+
"make the repo checkout available on disk."
|
|
78
|
+
)
|
|
79
|
+
spec = importlib.util.spec_from_file_location(
|
|
80
|
+
"argus._architecture_canonical", path,
|
|
81
|
+
)
|
|
82
|
+
if spec is None or spec.loader is None: # pragma: no cover — defensive
|
|
83
|
+
raise ImportError(f"Cannot build module spec for {path}")
|
|
84
|
+
module = importlib.util.module_from_spec(spec)
|
|
85
|
+
sys.modules["argus._architecture_canonical"] = module
|
|
86
|
+
spec.loader.exec_module(module)
|
|
87
|
+
return module
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def __getattr__(name: str) -> Any:
|
|
91
|
+
"""Lazily forward attribute lookups to the canonical module.
|
|
92
|
+
|
|
93
|
+
PEP 562 module-level ``__getattr__`` keeps the import cheap (the
|
|
94
|
+
canonical file isn't loaded until something actually asks for an
|
|
95
|
+
export). Anything not in ``_EXPORTS`` raises ``AttributeError``
|
|
96
|
+
so typos surface immediately.
|
|
97
|
+
"""
|
|
98
|
+
if name not in _EXPORTS:
|
|
99
|
+
raise AttributeError(f"module 'argus.architecture_map' has no attribute {name!r}")
|
|
100
|
+
module = _load_canonical()
|
|
101
|
+
return getattr(module, name)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def __dir__() -> list[str]: # pragma: no cover — IDE / repl niceties
|
|
105
|
+
return list(_EXPORTS)
|
argus/audit/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Audit module -- structured logging, audit trails, and CI platform detection."""
|
|
2
|
+
|
|
3
|
+
from .logger import get_logger
|
|
4
|
+
from .manifest import AuditManifest, create_manifest, finalize_manifest
|
|
5
|
+
from .platform import CIPlatform, detect_platform
|
|
6
|
+
from .secrets import mask_secrets
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AuditManifest",
|
|
10
|
+
"CIPlatform",
|
|
11
|
+
"create_manifest",
|
|
12
|
+
"detect_platform",
|
|
13
|
+
"finalize_manifest",
|
|
14
|
+
"get_logger",
|
|
15
|
+
"mask_secrets",
|
|
16
|
+
]
|
argus/audit/logger.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Structured logging with colored console output and JSON log file.
|
|
2
|
+
|
|
3
|
+
Dual output:
|
|
4
|
+
- Colored console on stderr (human-friendly)
|
|
5
|
+
- One-JSON-object-per-line log file (machine-friendly evidence)
|
|
6
|
+
|
|
7
|
+
All output is run through secret masking before being emitted.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .secrets import mask_secrets
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# ANSI color codes (stdlib only, no external deps)
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
class _Colors:
|
|
24
|
+
RESET = "\033[0m"
|
|
25
|
+
BOLD = "\033[1m"
|
|
26
|
+
RED = "\033[31m"
|
|
27
|
+
GREEN = "\033[32m"
|
|
28
|
+
YELLOW = "\033[33m"
|
|
29
|
+
BLUE = "\033[34m"
|
|
30
|
+
CYAN = "\033[36m"
|
|
31
|
+
GRAY = "\033[90m"
|
|
32
|
+
BG_RED = "\033[41m"
|
|
33
|
+
BG_GREEN = "\033[42m"
|
|
34
|
+
BG_YELLOW = "\033[43m"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_LEVEL_COLORS = {
|
|
38
|
+
logging.DEBUG: _Colors.GRAY,
|
|
39
|
+
logging.INFO: _Colors.CYAN,
|
|
40
|
+
logging.WARNING: _Colors.YELLOW,
|
|
41
|
+
logging.ERROR: _Colors.RED,
|
|
42
|
+
logging.CRITICAL: _Colors.BG_RED + _Colors.BOLD,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Formatters
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
class ColoredConsoleFormatter(logging.Formatter):
|
|
51
|
+
"""Colored formatter for terminal output. Masks secrets."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, use_color: bool = True):
|
|
54
|
+
super().__init__()
|
|
55
|
+
self._use_color = use_color
|
|
56
|
+
|
|
57
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
58
|
+
# Mask the rendered output, NOT record.msg — see the note in
|
|
59
|
+
# JsonLogFormatter.format() for why masking the format string
|
|
60
|
+
# breaks ``%s`` substitution.
|
|
61
|
+
ts = self.formatTime(record, "%H:%M:%S")
|
|
62
|
+
name = record.name.replace("argus.", "")
|
|
63
|
+
msg = mask_secrets(record.getMessage())
|
|
64
|
+
|
|
65
|
+
if not self._use_color:
|
|
66
|
+
return f"{ts} {record.levelname:<8} {name} {msg}"
|
|
67
|
+
|
|
68
|
+
color = _LEVEL_COLORS.get(record.levelno, "")
|
|
69
|
+
levelname = f"{color}{record.levelname:<8}{_Colors.RESET}"
|
|
70
|
+
ts_colored = f"{_Colors.GRAY}{ts}{_Colors.RESET}"
|
|
71
|
+
module = f"{_Colors.BLUE}{name}{_Colors.RESET}"
|
|
72
|
+
return f"{ts_colored} {levelname} {module} {msg}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class JsonLogFormatter(logging.Formatter):
|
|
76
|
+
"""Structured JSON formatter for log files. Masks secrets.
|
|
77
|
+
|
|
78
|
+
Writes one JSON object per line (JSONL) for easy parsing.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
82
|
+
# NOTE: do NOT mask ``record.msg`` directly. The msg is a
|
|
83
|
+
# printf-style format string; if its non-whitespace section
|
|
84
|
+
# matches a secret-masking pattern (e.g., ``"token: %s"`` matches
|
|
85
|
+
# ``r"token[=:]\s*[^\s]+"``), masking turns the format string
|
|
86
|
+
# into a literal with no placeholders, and the subsequent
|
|
87
|
+
# ``record.getMessage()`` raises TypeError on ``%s`` substitution.
|
|
88
|
+
# Mask the *rendered* message string below instead.
|
|
89
|
+
entry: dict = {
|
|
90
|
+
"timestamp": datetime.fromtimestamp(
|
|
91
|
+
record.created, tz=timezone.utc
|
|
92
|
+
).isoformat(),
|
|
93
|
+
"level": record.levelname,
|
|
94
|
+
"module": record.name,
|
|
95
|
+
"function": record.funcName,
|
|
96
|
+
"line": record.lineno,
|
|
97
|
+
# ``getMessage()`` re-renders the format string with
|
|
98
|
+
# ``record.args`` interpolated back in. If a caller passed
|
|
99
|
+
# a secret as a printf-style arg
|
|
100
|
+
# (``logger.info("token: %s", real_token)``), that secret
|
|
101
|
+
# never touched ``record.msg`` — only ``record.args`` — so
|
|
102
|
+
# masking ``record.msg`` above wouldn't catch it. We mask
|
|
103
|
+
# the *rendered* result to cover both paths.
|
|
104
|
+
"message": mask_secrets(record.getMessage()),
|
|
105
|
+
}
|
|
106
|
+
# Include extra fields if present (scanner name, phase, etc.)
|
|
107
|
+
for key in ("scanner", "phase", "image", "duration_ms"):
|
|
108
|
+
if hasattr(record, key):
|
|
109
|
+
entry[key] = getattr(record, key)
|
|
110
|
+
# Defense-in-depth: walk the assembled entry and re-mask any
|
|
111
|
+
# string a contributor might add to the ``extra`` set above
|
|
112
|
+
# without remembering to mask first. ``mask_secrets_in_obj``
|
|
113
|
+
# is idempotent — already-masked values pass through unchanged.
|
|
114
|
+
from argus.audit.secrets import mask_secrets_in_obj
|
|
115
|
+
return json.dumps(mask_secrets_in_obj(entry), default=str)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Public API
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def get_logger(
|
|
123
|
+
name: str = "argus",
|
|
124
|
+
output_dir: str | Path | None = None,
|
|
125
|
+
verbose: bool = False,
|
|
126
|
+
) -> logging.Logger:
|
|
127
|
+
"""Get a configured argus logger with colored console + JSON file output.
|
|
128
|
+
|
|
129
|
+
If *output_dir* is provided, writes structured JSON logs to
|
|
130
|
+
``{output_dir}/argus.log`` alongside scan results.
|
|
131
|
+
|
|
132
|
+
The logger is created once per *name*; subsequent calls with the same
|
|
133
|
+
name return the existing logger without adding duplicate handlers.
|
|
134
|
+
"""
|
|
135
|
+
logger = logging.getLogger(name)
|
|
136
|
+
if logger.handlers:
|
|
137
|
+
# Existing loggers should still honor a later verbose request,
|
|
138
|
+
# especially when shared across command flows.
|
|
139
|
+
for handler in logger.handlers:
|
|
140
|
+
if isinstance(handler, logging.StreamHandler):
|
|
141
|
+
desired_level = logging.DEBUG if verbose else logging.INFO
|
|
142
|
+
if handler.level != desired_level:
|
|
143
|
+
handler.setLevel(desired_level)
|
|
144
|
+
|
|
145
|
+
# Add file logging if this call requests it and none exists yet.
|
|
146
|
+
has_file_handler = any(
|
|
147
|
+
isinstance(handler, logging.FileHandler)
|
|
148
|
+
for handler in logger.handlers
|
|
149
|
+
)
|
|
150
|
+
if output_dir and not has_file_handler:
|
|
151
|
+
log_dir = Path(output_dir)
|
|
152
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
log_path = log_dir / "argus.log"
|
|
154
|
+
file_handler = logging.FileHandler(
|
|
155
|
+
log_path, mode="a", encoding="utf-8"
|
|
156
|
+
)
|
|
157
|
+
file_handler.setLevel(logging.DEBUG)
|
|
158
|
+
file_handler.setFormatter(JsonLogFormatter())
|
|
159
|
+
logger.addHandler(file_handler)
|
|
160
|
+
|
|
161
|
+
return logger
|
|
162
|
+
|
|
163
|
+
logger.setLevel(logging.DEBUG)
|
|
164
|
+
|
|
165
|
+
# Console handler -- colored, INFO level (DEBUG if verbose)
|
|
166
|
+
use_color = sys.stderr.isatty()
|
|
167
|
+
console = logging.StreamHandler(sys.stderr)
|
|
168
|
+
console.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
169
|
+
console.setFormatter(ColoredConsoleFormatter(use_color=use_color))
|
|
170
|
+
logger.addHandler(console)
|
|
171
|
+
|
|
172
|
+
# File handler -- JSON, DEBUG level (captures everything)
|
|
173
|
+
if output_dir:
|
|
174
|
+
log_dir = Path(output_dir)
|
|
175
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
log_path = log_dir / "argus.log"
|
|
177
|
+
file_handler = logging.FileHandler(
|
|
178
|
+
log_path, mode="a", encoding="utf-8"
|
|
179
|
+
)
|
|
180
|
+
file_handler.setLevel(logging.DEBUG)
|
|
181
|
+
file_handler.setFormatter(JsonLogFormatter())
|
|
182
|
+
logger.addHandler(file_handler)
|
|
183
|
+
|
|
184
|
+
return logger
|
argus/audit/manifest.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Audit manifest -- the evidence package JSON.
|
|
2
|
+
|
|
3
|
+
The manifest answers the forensic questions: what was scanned, when,
|
|
4
|
+
how, by whom, with what tools, and what was found. It is the single
|
|
5
|
+
file an investigator reaches for first.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import platform
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .platform import CIPlatform, detect_platform
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ScanPhase:
|
|
22
|
+
"""Timing data for one phase of the scan."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
started_at: str
|
|
26
|
+
completed_at: str = ""
|
|
27
|
+
duration_ms: int = 0
|
|
28
|
+
status: str = "success"
|
|
29
|
+
error: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class AuditManifest:
|
|
34
|
+
"""Complete audit trail for an argus scan run.
|
|
35
|
+
|
|
36
|
+
This is the evidence package. In a security investigation,
|
|
37
|
+
this file answers: what was scanned, when, how, by whom,
|
|
38
|
+
with what tools, and what was found.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
# Identity
|
|
42
|
+
argus_version: str = ""
|
|
43
|
+
scan_id: str = ""
|
|
44
|
+
|
|
45
|
+
# Timing
|
|
46
|
+
started_at: str = ""
|
|
47
|
+
completed_at: str = ""
|
|
48
|
+
duration_ms: int = 0
|
|
49
|
+
|
|
50
|
+
# Provenance (who / where)
|
|
51
|
+
platform: dict = field(default_factory=dict)
|
|
52
|
+
hostname: str = ""
|
|
53
|
+
python_version: str = ""
|
|
54
|
+
os_info: str = ""
|
|
55
|
+
|
|
56
|
+
# What was scanned
|
|
57
|
+
scan_targets: list[str] = field(default_factory=list)
|
|
58
|
+
config_file: str = ""
|
|
59
|
+
config_hash: str = ""
|
|
60
|
+
|
|
61
|
+
# How it was scanned
|
|
62
|
+
scanners_executed: list[str] = field(default_factory=list)
|
|
63
|
+
tool_versions: dict[str, str] = field(default_factory=dict)
|
|
64
|
+
container_images: dict[str, str] = field(default_factory=dict)
|
|
65
|
+
execution_backend: str = ""
|
|
66
|
+
|
|
67
|
+
# What was found (summary counts)
|
|
68
|
+
findings_summary: dict = field(default_factory=dict)
|
|
69
|
+
exit_code: int = 0
|
|
70
|
+
|
|
71
|
+
# Phases (timing per scanner / step)
|
|
72
|
+
phases: list[dict] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
# Artifact inventory
|
|
75
|
+
artifacts: list[dict] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
def save(self, output_dir: str | Path) -> Path:
|
|
78
|
+
"""Write the manifest to ``argus-audit.json``.
|
|
79
|
+
|
|
80
|
+
Every string in the manifest passes through
|
|
81
|
+
``mask_secrets_in_obj`` before serialization — defense-in-depth
|
|
82
|
+
so a future regression that captures a ``docker_cmd``,
|
|
83
|
+
environment dict, or credential-shaped argv into a manifest
|
|
84
|
+
field can't leak. Today the manifest schema doesn't include
|
|
85
|
+
such fields; the redaction pass is here so it stays that way
|
|
86
|
+
regardless of contributor vigilance.
|
|
87
|
+
"""
|
|
88
|
+
from argus.audit.secrets import mask_secrets_in_obj
|
|
89
|
+
|
|
90
|
+
dest = Path(output_dir)
|
|
91
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
filepath = dest / "argus-audit.json"
|
|
93
|
+
filepath.write_text(
|
|
94
|
+
json.dumps(
|
|
95
|
+
mask_secrets_in_obj(asdict(self)),
|
|
96
|
+
indent=2, default=str,
|
|
97
|
+
),
|
|
98
|
+
encoding="utf-8",
|
|
99
|
+
)
|
|
100
|
+
return filepath
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def create_manifest(
|
|
104
|
+
config_path: str | None = None,
|
|
105
|
+
scan_targets: list[str] | None = None,
|
|
106
|
+
) -> AuditManifest:
|
|
107
|
+
"""Create a new audit manifest with pre-filled provenance.
|
|
108
|
+
|
|
109
|
+
Call this at the start of a scan run, then pass the manifest through
|
|
110
|
+
``finalize_manifest`` when the scan completes.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
from argus import __version__
|
|
114
|
+
except ImportError:
|
|
115
|
+
__version__ = "unknown"
|
|
116
|
+
|
|
117
|
+
ci = detect_platform()
|
|
118
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
119
|
+
|
|
120
|
+
config_hash = ""
|
|
121
|
+
if config_path and Path(config_path).exists():
|
|
122
|
+
content = Path(config_path).read_bytes()
|
|
123
|
+
config_hash = hashlib.sha256(content).hexdigest()
|
|
124
|
+
|
|
125
|
+
return AuditManifest(
|
|
126
|
+
argus_version=__version__,
|
|
127
|
+
scan_id=str(uuid.uuid4()),
|
|
128
|
+
started_at=now,
|
|
129
|
+
platform=asdict(ci),
|
|
130
|
+
hostname=platform.node(),
|
|
131
|
+
python_version=platform.python_version(),
|
|
132
|
+
os_info=f"{platform.system()} {platform.release()}",
|
|
133
|
+
scan_targets=scan_targets or [],
|
|
134
|
+
config_file=config_path or "",
|
|
135
|
+
config_hash=config_hash,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def finalize_manifest(
|
|
140
|
+
manifest: AuditManifest,
|
|
141
|
+
summary: Any = None,
|
|
142
|
+
exit_code: int = 0,
|
|
143
|
+
output_dir: str | Path = "./argus-results",
|
|
144
|
+
) -> Path:
|
|
145
|
+
"""Finalize and save the audit manifest after scan completes.
|
|
146
|
+
|
|
147
|
+
Fills in: completion time, duration, findings summary, and an
|
|
148
|
+
inventory of every artifact in *output_dir* (with SHA-256 hashes).
|
|
149
|
+
"""
|
|
150
|
+
now = datetime.now(timezone.utc)
|
|
151
|
+
manifest.completed_at = now.isoformat()
|
|
152
|
+
|
|
153
|
+
if manifest.started_at:
|
|
154
|
+
started = datetime.fromisoformat(manifest.started_at)
|
|
155
|
+
manifest.duration_ms = int((now - started).total_seconds() * 1000)
|
|
156
|
+
|
|
157
|
+
manifest.exit_code = exit_code
|
|
158
|
+
|
|
159
|
+
# Summarize findings if a ScanSummary is provided
|
|
160
|
+
if summary and hasattr(summary, "results"):
|
|
161
|
+
manifest.scanners_executed = [r.scanner for r in summary.results]
|
|
162
|
+
manifest.findings_summary = {
|
|
163
|
+
"critical": getattr(summary, "critical_count", 0),
|
|
164
|
+
"high": getattr(summary, "high_count", 0),
|
|
165
|
+
"medium": getattr(summary, "medium_count", 0),
|
|
166
|
+
"low": getattr(summary, "low_count", 0),
|
|
167
|
+
"total": getattr(summary, "total_count", 0),
|
|
168
|
+
}
|
|
169
|
+
# Capture container image digests from scan metadata
|
|
170
|
+
for result in summary.results:
|
|
171
|
+
meta = getattr(result, "metadata", {}) or {}
|
|
172
|
+
if meta.get("image") and meta.get("digest"):
|
|
173
|
+
manifest.container_images[result.scanner] = {
|
|
174
|
+
"image": meta["image"],
|
|
175
|
+
"digest": meta["digest"],
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# Flush all log handlers so argus.log is complete before hashing.
|
|
179
|
+
# Without this, the log file hash in the manifest won't match the
|
|
180
|
+
# final file because the logger writes entries after hashing.
|
|
181
|
+
import logging
|
|
182
|
+
for handler in logging.getLogger("argus").handlers:
|
|
183
|
+
handler.flush()
|
|
184
|
+
|
|
185
|
+
# Inventory output artifacts (hash each file for integrity).
|
|
186
|
+
# Exclude argus-audit.json (self-referential) and argus.log
|
|
187
|
+
# (still being written to by the active logger).
|
|
188
|
+
_EXCLUDE_FROM_HASH = {"argus-audit.json", "argus.log"}
|
|
189
|
+
|
|
190
|
+
dest = Path(output_dir)
|
|
191
|
+
if dest.exists():
|
|
192
|
+
manifest.artifacts = [
|
|
193
|
+
{
|
|
194
|
+
"path": str(f.relative_to(dest)),
|
|
195
|
+
"size_bytes": f.stat().st_size,
|
|
196
|
+
"sha256": hashlib.sha256(f.read_bytes()).hexdigest(),
|
|
197
|
+
}
|
|
198
|
+
for f in sorted(dest.rglob("*"))
|
|
199
|
+
if f.is_file() and f.name not in _EXCLUDE_FROM_HASH
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
return manifest.save(output_dir)
|
argus/audit/platform.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""CI/CD platform detection.
|
|
2
|
+
|
|
3
|
+
Auto-detects the CI environment (GitHub Actions, GitLab CI, Jenkins,
|
|
4
|
+
or local dev) by inspecting well-known environment variables.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class CIPlatform:
|
|
13
|
+
"""Detected CI/CD platform metadata."""
|
|
14
|
+
|
|
15
|
+
name: str = "local"
|
|
16
|
+
pipeline_id: str = ""
|
|
17
|
+
job_id: str = ""
|
|
18
|
+
job_name: str = ""
|
|
19
|
+
job_url: str = ""
|
|
20
|
+
pipeline_url: str = ""
|
|
21
|
+
commit_sha: str = ""
|
|
22
|
+
commit_branch: str = ""
|
|
23
|
+
commit_message: str = ""
|
|
24
|
+
repository: str = ""
|
|
25
|
+
actor: str = ""
|
|
26
|
+
runner: str = ""
|
|
27
|
+
server_url: str = ""
|
|
28
|
+
event_name: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def detect_platform() -> CIPlatform:
|
|
32
|
+
"""Auto-detect CI platform from environment variables."""
|
|
33
|
+
if os.environ.get("GITHUB_ACTIONS") == "true":
|
|
34
|
+
return _detect_github()
|
|
35
|
+
if os.environ.get("GITLAB_CI") == "true":
|
|
36
|
+
return _detect_gitlab()
|
|
37
|
+
if os.environ.get("JENKINS_URL"):
|
|
38
|
+
return _detect_jenkins()
|
|
39
|
+
return CIPlatform(
|
|
40
|
+
name="local",
|
|
41
|
+
actor=os.environ.get("USER", "unknown"),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _detect_github() -> CIPlatform:
|
|
46
|
+
"""Extract metadata from GitHub Actions environment."""
|
|
47
|
+
server = os.environ.get("GITHUB_SERVER_URL", "")
|
|
48
|
+
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
|
49
|
+
run_id = os.environ.get("GITHUB_RUN_ID", "")
|
|
50
|
+
run_url = f"{server}/{repo}/actions/runs/{run_id}" if server and repo else ""
|
|
51
|
+
|
|
52
|
+
return CIPlatform(
|
|
53
|
+
name="github",
|
|
54
|
+
pipeline_id=run_id,
|
|
55
|
+
job_id=os.environ.get("GITHUB_JOB", ""),
|
|
56
|
+
job_name=os.environ.get("GITHUB_JOB", ""),
|
|
57
|
+
job_url=run_url,
|
|
58
|
+
pipeline_url=run_url,
|
|
59
|
+
commit_sha=os.environ.get("GITHUB_SHA", ""),
|
|
60
|
+
commit_branch=(
|
|
61
|
+
os.environ.get("GITHUB_HEAD_REF", "")
|
|
62
|
+
or os.environ.get("GITHUB_REF_NAME", "")
|
|
63
|
+
),
|
|
64
|
+
repository=repo,
|
|
65
|
+
actor=os.environ.get("GITHUB_ACTOR", ""),
|
|
66
|
+
runner=os.environ.get("RUNNER_NAME", ""),
|
|
67
|
+
server_url=server or "https://github.com",
|
|
68
|
+
event_name=os.environ.get("GITHUB_EVENT_NAME", ""),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _detect_gitlab() -> CIPlatform:
|
|
73
|
+
"""Extract metadata from GitLab CI environment."""
|
|
74
|
+
return CIPlatform(
|
|
75
|
+
name="gitlab",
|
|
76
|
+
pipeline_id=os.environ.get("CI_PIPELINE_ID", ""),
|
|
77
|
+
job_id=os.environ.get("CI_JOB_ID", ""),
|
|
78
|
+
job_name=os.environ.get("CI_JOB_NAME", ""),
|
|
79
|
+
job_url=os.environ.get("CI_JOB_URL", ""),
|
|
80
|
+
pipeline_url=os.environ.get("CI_PIPELINE_URL", ""),
|
|
81
|
+
commit_sha=os.environ.get("CI_COMMIT_SHA", ""),
|
|
82
|
+
commit_branch=(
|
|
83
|
+
os.environ.get("CI_COMMIT_BRANCH", "")
|
|
84
|
+
or os.environ.get("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME", "")
|
|
85
|
+
),
|
|
86
|
+
commit_message=os.environ.get("CI_COMMIT_MESSAGE", ""),
|
|
87
|
+
repository=os.environ.get("CI_PROJECT_PATH", ""),
|
|
88
|
+
actor=os.environ.get("GITLAB_USER_LOGIN", ""),
|
|
89
|
+
runner=os.environ.get("CI_RUNNER_DESCRIPTION", ""),
|
|
90
|
+
server_url=os.environ.get("CI_SERVER_URL", ""),
|
|
91
|
+
event_name=os.environ.get("CI_PIPELINE_SOURCE", ""),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _detect_jenkins() -> CIPlatform:
|
|
96
|
+
"""Extract metadata from Jenkins environment."""
|
|
97
|
+
return CIPlatform(
|
|
98
|
+
name="jenkins",
|
|
99
|
+
pipeline_id=os.environ.get("BUILD_NUMBER", ""),
|
|
100
|
+
job_id=os.environ.get("BUILD_ID", ""),
|
|
101
|
+
job_name=os.environ.get("JOB_NAME", ""),
|
|
102
|
+
job_url=os.environ.get("BUILD_URL", ""),
|
|
103
|
+
commit_sha=os.environ.get("GIT_COMMIT", ""),
|
|
104
|
+
commit_branch=os.environ.get("GIT_BRANCH", ""),
|
|
105
|
+
repository=os.environ.get("JOB_NAME", ""),
|
|
106
|
+
actor=os.environ.get("BUILD_USER", ""),
|
|
107
|
+
server_url=os.environ.get("JENKINS_URL", ""),
|
|
108
|
+
)
|