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.
Files changed (123) hide show
  1. argus/__init__.py +3 -0
  2. argus/__main__.py +6 -0
  3. argus/architecture_map.py +105 -0
  4. argus/audit/__init__.py +16 -0
  5. argus/audit/logger.py +184 -0
  6. argus/audit/manifest.py +202 -0
  7. argus/audit/platform.py +108 -0
  8. argus/audit/secrets.py +90 -0
  9. argus/cli.py +3372 -0
  10. argus/collect/__init__.py +10 -0
  11. argus/collect/collector.py +177 -0
  12. argus/collect/merger.py +237 -0
  13. argus/container/__init__.py +23 -0
  14. argus/container/builder.py +56 -0
  15. argus/container/discovery.py +281 -0
  16. argus/container/engine.py +283 -0
  17. argus/container/resources.py +166 -0
  18. argus/container/scanner.py +687 -0
  19. argus/container_runtime.py +148 -0
  20. argus/containers.py +149 -0
  21. argus/core/__init__.py +19 -0
  22. argus/core/config.py +299 -0
  23. argus/core/engine.py +1453 -0
  24. argus/core/exclusions.py +226 -0
  25. argus/core/findings_view.py +360 -0
  26. argus/core/image_verify.py +252 -0
  27. argus/core/linter_template.py +380 -0
  28. argus/core/models.py +371 -0
  29. argus/core/prewarm.py +216 -0
  30. argus/core/redact.py +216 -0
  31. argus/core/sbom.py +258 -0
  32. argus/core/scanner.py +48 -0
  33. argus/core/scanner_template.py +245 -0
  34. argus/core/schema.py +745 -0
  35. argus/core/secrets.py +182 -0
  36. argus/core/tool_config.py +177 -0
  37. argus/core/version.py +66 -0
  38. argus/dast/__init__.py +16 -0
  39. argus/dast/engine.py +458 -0
  40. argus/dast/inspect.py +145 -0
  41. argus/dast/runner.py +350 -0
  42. argus/init.py +508 -0
  43. argus/linters/__init__.py +54 -0
  44. argus/linters/eslint.py +235 -0
  45. argus/linters/hadolint.py +101 -0
  46. argus/linters/jsonlint.py +103 -0
  47. argus/linters/python_lint.py +116 -0
  48. argus/linters/terraform.py +202 -0
  49. argus/linters/yamllint.py +242 -0
  50. argus/mcp.py +1067 -0
  51. argus/preflight/__init__.py +1 -0
  52. argus/preflight/ci_provider.py +77 -0
  53. argus/preflight/issue_reporter.py +227 -0
  54. argus/preflight/network_deps.py +36 -0
  55. argus/preflight/report_body.py +101 -0
  56. argus/preflight/tool_check.py +98 -0
  57. argus/reporters/__init__.py +312 -0
  58. argus/reporters/container_markdown.py +450 -0
  59. argus/reporters/github.py +131 -0
  60. argus/reporters/gitlab.py +155 -0
  61. argus/reporters/json_report.py +30 -0
  62. argus/reporters/junit.py +211 -0
  63. argus/reporters/markdown.py +181 -0
  64. argus/reporters/sarif.py +118 -0
  65. argus/reporters/terminal.py +165 -0
  66. argus/scanners/__init__.py +92 -0
  67. argus/scanners/_vuln_parsers.py +71 -0
  68. argus/scanners/bandit.py +133 -0
  69. argus/scanners/checkov.py +135 -0
  70. argus/scanners/clamav.py +131 -0
  71. argus/scanners/container.py +971 -0
  72. argus/scanners/gitleaks.py +128 -0
  73. argus/scanners/grype.py +146 -0
  74. argus/scanners/opengrep.py +95 -0
  75. argus/scanners/osv.py +188 -0
  76. argus/scanners/supply_chain.py +325 -0
  77. argus/scanners/trivy.py +116 -0
  78. argus/scanners/trivy_iac.py +137 -0
  79. argus/scanners/zap.py +345 -0
  80. argus/scn/__init__.py +4 -0
  81. argus/scn/ai.py +369 -0
  82. argus/scn/classifier.py +284 -0
  83. argus/scn/config.py +350 -0
  84. argus/scn/defaults.py +143 -0
  85. argus/scn/diff.py +503 -0
  86. argus/scn/report.py +375 -0
  87. argus/scn/schemas/__init__.py +27 -0
  88. argus/update_check.py +266 -0
  89. argus/viewers/__init__.py +34 -0
  90. argus/viewers/browser/__init__.py +80 -0
  91. argus/viewers/browser/app.py +1061 -0
  92. argus/viewers/browser/log_view.py +177 -0
  93. argus/viewers/browser/static/architecture.css +1355 -0
  94. argus/viewers/browser/static/architecture.js +1494 -0
  95. argus/viewers/browser/static/argus.css +1021 -0
  96. argus/viewers/browser/static/auto-filter.js +109 -0
  97. argus/viewers/browser/static/export-copy.js +98 -0
  98. argus/viewers/browser/static/favicon.png +0 -0
  99. argus/viewers/browser/static/picker-compare.js +76 -0
  100. argus/viewers/browser/static/scan-mtime.js +35 -0
  101. argus/viewers/browser/static/theme-toggle.js +84 -0
  102. argus/viewers/browser/templates/_findings_table.html.j2 +144 -0
  103. argus/viewers/browser/templates/base.html.j2 +99 -0
  104. argus/viewers/browser/templates/diff.html.j2 +120 -0
  105. argus/viewers/browser/templates/findings.html.j2 +118 -0
  106. argus/viewers/browser/templates/log.html.j2 +68 -0
  107. argus/viewers/browser/templates/picker.html.j2 +129 -0
  108. argus/viewers/browser/templates/summary.html.j2 +216 -0
  109. argus/viewers/diagnose.py +150 -0
  110. argus/viewers/terminal/__init__.py +47 -0
  111. argus/viewers/terminal/app.py +2434 -0
  112. argus/viewers/terminal/clipboard.py +151 -0
  113. argus/viewers/terminal/export.py +288 -0
  114. argus/viewers/terminal/loader.py +71 -0
  115. argus/viewers/terminal/mouse_actions.py +537 -0
  116. argus-config.schema.json +298 -0
  117. argus_security-1.0.1.dist-info/METADATA +825 -0
  118. argus_security-1.0.1.dist-info/RECORD +123 -0
  119. argus_security-1.0.1.dist-info/WHEEL +5 -0
  120. argus_security-1.0.1.dist-info/entry_points.txt +12 -0
  121. argus_security-1.0.1.dist-info/licenses/LICENSE.md +673 -0
  122. argus_security-1.0.1.dist-info/top_level.txt +1 -0
  123. img/argus_logo.txt +30 -0
argus/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Argus Security Scanner SDK."""
2
+
3
+ __version__ = "1.0.1"
argus/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running argus as a module: python -m argus"""
2
+
3
+ from argus.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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)
@@ -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
@@ -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)
@@ -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
+ )