secure-code-agent 0.2.0__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.
- secure_code_agent-0.2.0.dist-info/METADATA +328 -0
- secure_code_agent-0.2.0.dist-info/RECORD +33 -0
- secure_code_agent-0.2.0.dist-info/WHEEL +5 -0
- secure_code_agent-0.2.0.dist-info/entry_points.txt +3 -0
- secure_code_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
- secure_code_agent-0.2.0.dist-info/top_level.txt +1 -0
- secure_code_audit/__init__.py +3 -0
- secure_code_audit/baseline.py +110 -0
- secure_code_audit/cli.py +258 -0
- secure_code_audit/config.py +115 -0
- secure_code_audit/findings.py +165 -0
- secure_code_audit/git_tools.py +82 -0
- secure_code_audit/instructions.py +141 -0
- secure_code_audit/remediation.py +168 -0
- secure_code_audit/renderers.py +253 -0
- secure_code_audit/sarif.py +221 -0
- secure_code_audit/scanners/__init__.py +50 -0
- secure_code_audit/scanners/bandit_scanner.py +83 -0
- secure_code_audit/scanners/base.py +194 -0
- secure_code_audit/scanners/builtin_rules.py +183 -0
- secure_code_audit/scanners/checkov_scanner.py +69 -0
- secure_code_audit/scanners/gitleaks_scanner.py +86 -0
- secure_code_audit/scanners/hadolint_scanner.py +107 -0
- secure_code_audit/scanners/npm_audit_scanner.py +101 -0
- secure_code_audit/scanners/osv_scanner.py +108 -0
- secure_code_audit/scanners/pip_audit_scanner.py +83 -0
- secure_code_audit/scanners/scorecard_scanner.py +156 -0
- secure_code_audit/scanners/semgrep_scanner.py +119 -0
- secure_code_audit/scanners/trivy_scanner.py +87 -0
- secure_code_audit/scanners/trufflehog_scanner.py +91 -0
- secure_code_audit/scoring.py +280 -0
- secure_code_audit/standards.py +391 -0
- secure_code_audit/suppressions.py +175 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Built-in regex rules — high-confidence, low-false-positive patterns that
|
|
2
|
+
catch what the big scanners miss or that we want to ship even when no
|
|
3
|
+
external scanner is installed.
|
|
4
|
+
|
|
5
|
+
The rules are opinionated and SMALL. We are not building a parallel
|
|
6
|
+
Semgrep. Each rule:
|
|
7
|
+
· targets a single CWE
|
|
8
|
+
· is documented inline (when it's useful + when it false-positives)
|
|
9
|
+
· is covered by at least one true-positive and one false-positive fixture
|
|
10
|
+
in tests/fixtures/builtin_rules/
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import NamedTuple
|
|
17
|
+
|
|
18
|
+
from secure_code_audit.config import Config
|
|
19
|
+
from secure_code_audit.findings import Confidence, Finding, Severity
|
|
20
|
+
from secure_code_audit.git_tools import in_scope, is_excluded
|
|
21
|
+
from secure_code_audit.scanners.base import Scanner
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Rule(NamedTuple):
|
|
25
|
+
rule_id: str
|
|
26
|
+
pattern: re.Pattern[str]
|
|
27
|
+
file_globs: tuple[str, ...] # restrict to certain languages (e.g. ".py")
|
|
28
|
+
description: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --- the rule set ---------------------------------------------------------
|
|
32
|
+
# Each pattern is tuned for ≥90% precision on real-world code. Update with
|
|
33
|
+
# a counter-example in tests/fixtures/ if you tighten or loosen.
|
|
34
|
+
|
|
35
|
+
_RULES: tuple[_Rule, ...] = (
|
|
36
|
+
_Rule(
|
|
37
|
+
rule_id="sca.python.eval",
|
|
38
|
+
# eval/exec with a non-string-literal first arg. Allow eval('lit').
|
|
39
|
+
pattern=re.compile(
|
|
40
|
+
r"\b(?:eval|exec)\s*\(\s*(?!['\"][^'\"]*['\"]\s*\))",
|
|
41
|
+
re.MULTILINE,
|
|
42
|
+
),
|
|
43
|
+
file_globs=(".py",),
|
|
44
|
+
description="eval()/exec() with non-literal input — arbitrary code execution.",
|
|
45
|
+
),
|
|
46
|
+
_Rule(
|
|
47
|
+
rule_id="sca.python.yaml.unsafe_load",
|
|
48
|
+
pattern=re.compile(
|
|
49
|
+
r"\byaml\.load\s*\(\s*(?!.*Loader\s*=\s*(?:yaml\.)?SafeLoader)",
|
|
50
|
+
re.MULTILINE,
|
|
51
|
+
),
|
|
52
|
+
file_globs=(".py",),
|
|
53
|
+
description="yaml.load() without SafeLoader — unsafe deserialization.",
|
|
54
|
+
),
|
|
55
|
+
_Rule(
|
|
56
|
+
rule_id="sca.python.requests.verify_false",
|
|
57
|
+
pattern=re.compile(
|
|
58
|
+
r"\brequests\.(?:get|post|put|patch|delete|head|options|request)\s*\([^)]*verify\s*=\s*False",
|
|
59
|
+
re.MULTILINE | re.DOTALL,
|
|
60
|
+
),
|
|
61
|
+
file_globs=(".py",),
|
|
62
|
+
description="requests.* called with verify=False — TLS cert validation disabled.",
|
|
63
|
+
),
|
|
64
|
+
_Rule(
|
|
65
|
+
rule_id="sca.python.subprocess.shell_true",
|
|
66
|
+
pattern=re.compile(
|
|
67
|
+
r"\bsubprocess\.(?:run|call|check_call|check_output|Popen)\s*\([^)]*shell\s*=\s*True",
|
|
68
|
+
re.MULTILINE | re.DOTALL,
|
|
69
|
+
),
|
|
70
|
+
file_globs=(".py",),
|
|
71
|
+
description="subprocess with shell=True — command-injection risk.",
|
|
72
|
+
),
|
|
73
|
+
_Rule(
|
|
74
|
+
rule_id="sca.python.fstring_sql",
|
|
75
|
+
# Look for f-strings containing SELECT/INSERT/UPDATE/DELETE
|
|
76
|
+
# interpolated into .execute( / .executemany(. We require both the
|
|
77
|
+
# f-string AND the execute-call to be within ~200 chars to keep
|
|
78
|
+
# false positives down.
|
|
79
|
+
pattern=re.compile(
|
|
80
|
+
r"\.(?:execute|executemany)\s*\(\s*f['\"](?:[^'\"]*?\b(?:SELECT|INSERT|UPDATE|DELETE|MERGE)\b[^'\"]*?\{[^}]+\})",
|
|
81
|
+
re.IGNORECASE | re.MULTILINE,
|
|
82
|
+
),
|
|
83
|
+
file_globs=(".py",),
|
|
84
|
+
description="f-string SQL in .execute()/.executemany() — possible SQL injection.",
|
|
85
|
+
),
|
|
86
|
+
_Rule(
|
|
87
|
+
rule_id="sca.python.hashlib.md5_sha1_security",
|
|
88
|
+
# MD5/SHA-1 — false-positives in non-security use are common. We
|
|
89
|
+
# only flag when usedforsecurity is not explicitly False.
|
|
90
|
+
pattern=re.compile(
|
|
91
|
+
r"\bhashlib\.(?:md5|sha1)\s*\((?![^)]*usedforsecurity\s*=\s*False)",
|
|
92
|
+
re.MULTILINE,
|
|
93
|
+
),
|
|
94
|
+
file_globs=(".py",),
|
|
95
|
+
description="MD5/SHA-1 without usedforsecurity=False — weak hash for security context.",
|
|
96
|
+
),
|
|
97
|
+
_Rule(
|
|
98
|
+
rule_id="sca.web.dangerously_set_inner_html",
|
|
99
|
+
pattern=re.compile(
|
|
100
|
+
r"dangerouslySetInnerHTML\s*=\s*\{\{\s*__html\s*:",
|
|
101
|
+
re.MULTILINE,
|
|
102
|
+
),
|
|
103
|
+
file_globs=(".tsx", ".jsx", ".ts", ".js"),
|
|
104
|
+
description="React dangerouslySetInnerHTML — XSS surface unless input is trusted/sanitized.",
|
|
105
|
+
),
|
|
106
|
+
_Rule(
|
|
107
|
+
rule_id="sca.web.cors_wildcard",
|
|
108
|
+
# Headers/middleware setting Allow-Origin '*'. Pair with
|
|
109
|
+
# Allow-Credentials: true → spec violation + XSCSRF surface.
|
|
110
|
+
pattern=re.compile(
|
|
111
|
+
r"(?i)Access-Control-Allow-Origin\s*['\"]?\s*[:=]\s*['\"]?\*",
|
|
112
|
+
),
|
|
113
|
+
file_globs=(".py", ".js", ".ts", ".tsx", ".go", ".rb", ".java"),
|
|
114
|
+
description="CORS Allow-Origin: * — review for credentialed-cookies exposure.",
|
|
115
|
+
),
|
|
116
|
+
_Rule(
|
|
117
|
+
rule_id="sca.shell.curl_pipe_sh",
|
|
118
|
+
pattern=re.compile(
|
|
119
|
+
r"\bcurl\b[^|;\n]+\|\s*(?:bash|sh|zsh)\b",
|
|
120
|
+
re.MULTILINE,
|
|
121
|
+
),
|
|
122
|
+
file_globs=(".sh", "Dockerfile"),
|
|
123
|
+
description="curl ... | sh — unauthenticated remote-script execution.",
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class BuiltinRulesScanner(Scanner):
|
|
129
|
+
name = "builtin_rules"
|
|
130
|
+
binary = "" # in-process scanner
|
|
131
|
+
|
|
132
|
+
def is_available(self) -> bool:
|
|
133
|
+
return True # no external binary needed
|
|
134
|
+
|
|
135
|
+
def binary_version(self) -> str | None:
|
|
136
|
+
from secure_code_audit import __version__
|
|
137
|
+
return f"builtin/{__version__}"
|
|
138
|
+
|
|
139
|
+
def run(self, target: Path, config: Config) -> list[Finding]:
|
|
140
|
+
findings: list[Finding] = []
|
|
141
|
+
for path in self._candidate_files(target, config):
|
|
142
|
+
try:
|
|
143
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
144
|
+
except OSError:
|
|
145
|
+
continue
|
|
146
|
+
for rule in _RULES:
|
|
147
|
+
if not self._applies_to(path, rule):
|
|
148
|
+
continue
|
|
149
|
+
for m in rule.pattern.finditer(text):
|
|
150
|
+
line_start = text.count("\n", 0, m.start()) + 1
|
|
151
|
+
line_end = text.count("\n", 0, m.end()) + 1
|
|
152
|
+
snippet = m.group(0)[:200]
|
|
153
|
+
findings.append(self._make_finding(
|
|
154
|
+
rule_id=rule.rule_id,
|
|
155
|
+
message=rule.description,
|
|
156
|
+
file_path=path,
|
|
157
|
+
line_start=line_start,
|
|
158
|
+
line_end=line_end if line_end != line_start else None,
|
|
159
|
+
code_snippet=snippet,
|
|
160
|
+
severity=Severity.HIGH,
|
|
161
|
+
confidence=Confidence.MEDIUM,
|
|
162
|
+
))
|
|
163
|
+
return findings
|
|
164
|
+
|
|
165
|
+
def _applies_to(self, path: Path, rule: _Rule) -> bool:
|
|
166
|
+
name = path.name
|
|
167
|
+
for glob in rule.file_globs:
|
|
168
|
+
if glob.startswith("."):
|
|
169
|
+
if name.endswith(glob):
|
|
170
|
+
return True
|
|
171
|
+
elif name == glob:
|
|
172
|
+
return True
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
def _candidate_files(self, target: Path, config: Config):
|
|
176
|
+
for path in target.rglob("*"):
|
|
177
|
+
if not path.is_file():
|
|
178
|
+
continue
|
|
179
|
+
if is_excluded(path, target, config.exclude_patterns):
|
|
180
|
+
continue
|
|
181
|
+
if not in_scope(path, config.include_extensions):
|
|
182
|
+
continue
|
|
183
|
+
yield path
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Checkov — IaC scanning (Terraform / CloudFormation / Helm / k8s / Dockerfile).
|
|
2
|
+
|
|
3
|
+
Invocation:
|
|
4
|
+
checkov -d <target> --output sarif --output-file-path <tmp> --quiet --soft-fail
|
|
5
|
+
|
|
6
|
+
`--soft-fail` makes checkov exit 0 even on findings; we want our own
|
|
7
|
+
gate logic to drive exit codes, not checkov's. SARIF goes through the
|
|
8
|
+
canonical ingest.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import tempfile
|
|
13
|
+
from dataclasses import replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from secure_code_audit.config import Config
|
|
17
|
+
from secure_code_audit.findings import Category, Confidence, Finding, Severity
|
|
18
|
+
from secure_code_audit.sarif import ingest as sarif_ingest
|
|
19
|
+
from secure_code_audit.scanners.base import Scanner
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CheckovScanner(Scanner):
|
|
23
|
+
name = "checkov"
|
|
24
|
+
binary = "checkov"
|
|
25
|
+
default_category = Category.CONFIG_IAC
|
|
26
|
+
|
|
27
|
+
def run(self, target: Path, config: Config) -> list[Finding]:
|
|
28
|
+
if not self.is_available():
|
|
29
|
+
return [self._unavailable_finding(target)]
|
|
30
|
+
|
|
31
|
+
sc_cfg = self.cfg(config)
|
|
32
|
+
# Checkov writes to a directory and names the file itself.
|
|
33
|
+
# Use a temp directory so cleanup is straightforward.
|
|
34
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
35
|
+
tmpdir_path = Path(tmpdir)
|
|
36
|
+
args = [
|
|
37
|
+
self.binary, "-d", str(target),
|
|
38
|
+
"--output", "sarif",
|
|
39
|
+
"--output-file-path", str(tmpdir_path),
|
|
40
|
+
"--quiet", "--soft-fail",
|
|
41
|
+
]
|
|
42
|
+
args.extend(sc_cfg.extra_args)
|
|
43
|
+
|
|
44
|
+
r = self._exec(args, cwd=target, timeout_seconds=sc_cfg.timeout_seconds,
|
|
45
|
+
allowed_exits=(0, 1, 2))
|
|
46
|
+
if r.returncode == 124:
|
|
47
|
+
return [self._make_finding(
|
|
48
|
+
rule_id=f"{self.name}.tool_timeout",
|
|
49
|
+
message=f"checkov timed out: {r.stderr[:200]}",
|
|
50
|
+
file_path=target, line_start=0, line_end=None, code_snippet=None,
|
|
51
|
+
severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
|
|
52
|
+
)]
|
|
53
|
+
|
|
54
|
+
# Checkov writes results.sarif into the output directory.
|
|
55
|
+
sarif_path = tmpdir_path / "results_sarif.sarif"
|
|
56
|
+
if not sarif_path.exists():
|
|
57
|
+
# Older versions write `results.sarif` instead.
|
|
58
|
+
alt = tmpdir_path / "results.sarif"
|
|
59
|
+
sarif_path = alt if alt.exists() else sarif_path
|
|
60
|
+
if not sarif_path.exists():
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
ingested = sarif_ingest(sarif_path, default_scanner="checkov")
|
|
64
|
+
# All checkov findings are config_iac by rule family. Pin
|
|
65
|
+
# the scanner field for cross-scanner dedupe clarity.
|
|
66
|
+
return [
|
|
67
|
+
replace(f, scanner="checkov", category=Category.CONFIG_IAC)
|
|
68
|
+
for f in ingested
|
|
69
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Gitleaks — history-aware secret scanning.
|
|
2
|
+
|
|
3
|
+
Invocation:
|
|
4
|
+
gitleaks detect --source=<target> --no-banner \
|
|
5
|
+
--redact --report-format=json --report-path=<tmp>
|
|
6
|
+
|
|
7
|
+
We always pass --redact so the raw secret never reaches the report JSON.
|
|
8
|
+
The fingerprint hashes the raw match site so dedupe still works.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from secure_code_audit.config import Config
|
|
17
|
+
from secure_code_audit.findings import Category, Confidence, Finding, Severity
|
|
18
|
+
from secure_code_audit.scanners.base import Scanner
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GitleaksScanner(Scanner):
|
|
22
|
+
name = "gitleaks"
|
|
23
|
+
binary = "gitleaks"
|
|
24
|
+
default_category = Category.SECRETS
|
|
25
|
+
|
|
26
|
+
def run(self, target: Path, config: Config) -> list[Finding]:
|
|
27
|
+
if not self.is_available():
|
|
28
|
+
return [self._unavailable_finding(target)]
|
|
29
|
+
|
|
30
|
+
sc_cfg = self.cfg(config)
|
|
31
|
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
|
|
32
|
+
report_path = Path(tmp.name)
|
|
33
|
+
try:
|
|
34
|
+
args = [
|
|
35
|
+
self.binary, "detect",
|
|
36
|
+
"--source", str(target),
|
|
37
|
+
"--no-banner",
|
|
38
|
+
"--redact",
|
|
39
|
+
"--report-format", "json",
|
|
40
|
+
"--report-path", str(report_path),
|
|
41
|
+
]
|
|
42
|
+
args.extend(sc_cfg.extra_args)
|
|
43
|
+
# Gitleaks exits 1 when findings exist; 0 = clean; >1 = error.
|
|
44
|
+
r = self._exec(args, cwd=target, timeout_seconds=sc_cfg.timeout_seconds,
|
|
45
|
+
allowed_exits=(0, 1))
|
|
46
|
+
if r.returncode not in (0, 1):
|
|
47
|
+
return [self._make_finding(
|
|
48
|
+
rule_id=f"{self.name}.tool_error",
|
|
49
|
+
message=f"gitleaks failed: {r.stderr[:300]}",
|
|
50
|
+
file_path=target, line_start=0, line_end=None, code_snippet=None,
|
|
51
|
+
severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
|
|
52
|
+
)]
|
|
53
|
+
if not report_path.exists() or report_path.stat().st_size == 0:
|
|
54
|
+
return []
|
|
55
|
+
try:
|
|
56
|
+
payload = json.loads(report_path.read_text(encoding="utf-8"))
|
|
57
|
+
except json.JSONDecodeError:
|
|
58
|
+
return []
|
|
59
|
+
return self._parse(payload, target)
|
|
60
|
+
finally:
|
|
61
|
+
report_path.unlink(missing_ok=True)
|
|
62
|
+
|
|
63
|
+
def _parse(self, payload: list[dict], target: Path) -> list[Finding]:
|
|
64
|
+
if not isinstance(payload, list):
|
|
65
|
+
return []
|
|
66
|
+
findings: list[Finding] = []
|
|
67
|
+
for hit in payload:
|
|
68
|
+
rule_id = str(hit.get("RuleID") or hit.get("Rule") or "unknown")
|
|
69
|
+
file_path = Path(hit.get("File") or "")
|
|
70
|
+
line = int(hit.get("StartLine") or 0)
|
|
71
|
+
redacted_match = str(hit.get("Match") or hit.get("Secret") or "").strip()
|
|
72
|
+
# Gitleaks --redact returns the match string with the secret
|
|
73
|
+
# replaced by REDACTED — we surface that exact string in the
|
|
74
|
+
# report.
|
|
75
|
+
findings.append(self._make_finding(
|
|
76
|
+
rule_id=f"gitleaks.{rule_id}",
|
|
77
|
+
message=f"{hit.get('Description') or rule_id}: {redacted_match}".strip(),
|
|
78
|
+
file_path=file_path,
|
|
79
|
+
line_start=line,
|
|
80
|
+
line_end=int(hit.get("EndLine") or line) if hit.get("EndLine") else None,
|
|
81
|
+
code_snippet=redacted_match[:200] or None,
|
|
82
|
+
severity=Severity.CRITICAL,
|
|
83
|
+
confidence=Confidence.HIGH,
|
|
84
|
+
category=Category.SECRETS,
|
|
85
|
+
))
|
|
86
|
+
return findings
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Hadolint — Dockerfile linter.
|
|
2
|
+
|
|
3
|
+
Invocation:
|
|
4
|
+
hadolint --no-fail --format json <Dockerfile> [<Dockerfile> ...]
|
|
5
|
+
|
|
6
|
+
Hadolint emits a JSON array — one entry per finding with file/line/code
|
|
7
|
+
(rule id like DL3001) / level / message. We walk the target tree for
|
|
8
|
+
Dockerfiles and pass them all to one hadolint invocation.
|
|
9
|
+
|
|
10
|
+
Only a subset of DL/SC rules have security implications; we map those
|
|
11
|
+
in standards.py (see SECURITY_RELEVANT_RULES). Style-only rules
|
|
12
|
+
(DL3007, DL3008 unpinned-apt) are tagged config_iac at LOW severity.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import replace
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from secure_code_audit.config import Config
|
|
21
|
+
from secure_code_audit.findings import Category, Confidence, Finding, Severity
|
|
22
|
+
from secure_code_audit.git_tools import is_excluded
|
|
23
|
+
from secure_code_audit.scanners.base import Scanner
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Hadolint rule ids whose semantics are security-relevant. The category
|
|
27
|
+
# stays config_iac but severity is bumped over the default LOW.
|
|
28
|
+
_HIGH_SECURITY: dict[str, Severity] = {
|
|
29
|
+
"DL3002": Severity.HIGH, # USER root
|
|
30
|
+
"DL3004": Severity.MEDIUM, # do not use sudo
|
|
31
|
+
"DL3025": Severity.MEDIUM, # use JSON form for CMD/ENTRYPOINT (shell injection surface)
|
|
32
|
+
"DL4006": Severity.MEDIUM, # set SHELL with pipefail
|
|
33
|
+
"SC2086": Severity.MEDIUM, # unquoted variable (shell-injection)
|
|
34
|
+
"SC2046": Severity.MEDIUM, # unquoted command substitution
|
|
35
|
+
"DL3023": Severity.MEDIUM, # COPY --from points to its own FROM alias
|
|
36
|
+
"DL3033": Severity.MEDIUM, # specify version with yum install -y
|
|
37
|
+
"DL3008": Severity.LOW, # pin apt versions
|
|
38
|
+
"DL3009": Severity.LOW, # delete apt lists after install
|
|
39
|
+
"DL3015": Severity.LOW, # use --no-install-recommends
|
|
40
|
+
"DL3018": Severity.LOW, # pin apk versions
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class HadolintScanner(Scanner):
|
|
45
|
+
name = "hadolint"
|
|
46
|
+
binary = "hadolint"
|
|
47
|
+
default_category = Category.CONFIG_IAC
|
|
48
|
+
|
|
49
|
+
def run(self, target: Path, config: Config) -> list[Finding]:
|
|
50
|
+
if not self.is_available():
|
|
51
|
+
return [self._unavailable_finding(target)]
|
|
52
|
+
|
|
53
|
+
dockerfiles = self._find_dockerfiles(target, config.exclude_patterns)
|
|
54
|
+
if not dockerfiles:
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
sc_cfg = self.cfg(config)
|
|
58
|
+
args = [self.binary, "--no-fail", "--format", "json"]
|
|
59
|
+
args.extend(str(p) for p in dockerfiles)
|
|
60
|
+
args.extend(sc_cfg.extra_args)
|
|
61
|
+
|
|
62
|
+
r = self._exec(args, cwd=target, timeout_seconds=sc_cfg.timeout_seconds,
|
|
63
|
+
allowed_exits=(0,))
|
|
64
|
+
if r.returncode == 124:
|
|
65
|
+
return [self._make_finding(
|
|
66
|
+
rule_id=f"{self.name}.tool_timeout",
|
|
67
|
+
message=f"hadolint timed out: {r.stderr[:200]}",
|
|
68
|
+
file_path=target, line_start=0, line_end=None, code_snippet=None,
|
|
69
|
+
severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
|
|
70
|
+
)]
|
|
71
|
+
if not r.stdout.strip():
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
payload = json.loads(r.stdout)
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
return []
|
|
78
|
+
if not isinstance(payload, list):
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
return [self._parse_one(item) for item in payload if isinstance(item, dict)]
|
|
82
|
+
|
|
83
|
+
def _find_dockerfiles(self, target: Path, excludes) -> list[Path]:
|
|
84
|
+
out: list[Path] = []
|
|
85
|
+
for path in target.rglob("Dockerfile*"):
|
|
86
|
+
if not path.is_file():
|
|
87
|
+
continue
|
|
88
|
+
if is_excluded(path, target, excludes):
|
|
89
|
+
continue
|
|
90
|
+
out.append(path)
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
def _parse_one(self, item: dict) -> Finding:
|
|
94
|
+
code = str(item.get("code") or "unknown")
|
|
95
|
+
severity = _HIGH_SECURITY.get(code, Severity.LOW)
|
|
96
|
+
finding = self._make_finding(
|
|
97
|
+
rule_id=f"hadolint.{code}",
|
|
98
|
+
message=str(item.get("message") or code),
|
|
99
|
+
file_path=Path(item.get("file") or ""),
|
|
100
|
+
line_start=int(item.get("line") or 0),
|
|
101
|
+
line_end=None,
|
|
102
|
+
code_snippet=None,
|
|
103
|
+
severity=severity,
|
|
104
|
+
confidence=Confidence.HIGH,
|
|
105
|
+
category=Category.CONFIG_IAC,
|
|
106
|
+
)
|
|
107
|
+
return finding
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""npm audit — Node SCA.
|
|
2
|
+
|
|
3
|
+
Invocation:
|
|
4
|
+
npm audit --json --omit=dev [--audit-level=low]
|
|
5
|
+
|
|
6
|
+
npm audit exits nonzero when findings exist; we accept 0+1.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from secure_code_audit.config import Config
|
|
14
|
+
from secure_code_audit.findings import Category, Confidence, Finding, Severity
|
|
15
|
+
from secure_code_audit.scanners.base import Scanner
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_NPM_SEVERITY: dict[str, Severity] = {
|
|
19
|
+
"critical": Severity.CRITICAL,
|
|
20
|
+
"high": Severity.HIGH,
|
|
21
|
+
"moderate": Severity.MEDIUM,
|
|
22
|
+
"low": Severity.LOW,
|
|
23
|
+
"info": Severity.INFORMATIONAL,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NpmAuditScanner(Scanner):
|
|
28
|
+
name = "npm_audit"
|
|
29
|
+
binary = "npm"
|
|
30
|
+
default_category = Category.DEPENDENCIES
|
|
31
|
+
|
|
32
|
+
def run(self, target: Path, config: Config) -> list[Finding]:
|
|
33
|
+
if not self.is_available():
|
|
34
|
+
return [self._unavailable_finding(target)]
|
|
35
|
+
|
|
36
|
+
pkg_dirs = self._discover_package_dirs(target, config.exclude_patterns)
|
|
37
|
+
if not pkg_dirs:
|
|
38
|
+
return [self._make_finding(
|
|
39
|
+
rule_id=f"{self.name}.no_package_lock",
|
|
40
|
+
message="No package-lock.json found in scope; npm audit skipped.",
|
|
41
|
+
file_path=target, line_start=0, line_end=None, code_snippet=None,
|
|
42
|
+
severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
|
|
43
|
+
category=Category.DEPENDENCIES,
|
|
44
|
+
)]
|
|
45
|
+
|
|
46
|
+
sc_cfg = self.cfg(config)
|
|
47
|
+
findings: list[Finding] = []
|
|
48
|
+
for pkg_dir in pkg_dirs:
|
|
49
|
+
args = [self.binary, "audit", "--json", "--omit=dev"]
|
|
50
|
+
args.extend(sc_cfg.extra_args)
|
|
51
|
+
r = self._exec(args, cwd=pkg_dir, timeout_seconds=sc_cfg.timeout_seconds,
|
|
52
|
+
allowed_exits=(0, 1))
|
|
53
|
+
if r.returncode == 124 or not r.stdout.strip():
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
payload = json.loads(r.stdout)
|
|
57
|
+
except json.JSONDecodeError:
|
|
58
|
+
continue
|
|
59
|
+
findings.extend(self._parse(payload, pkg_dir))
|
|
60
|
+
return findings
|
|
61
|
+
|
|
62
|
+
def _discover_package_dirs(self, root: Path, excludes: tuple[str, ...]) -> list[Path]:
|
|
63
|
+
out: list[Path] = []
|
|
64
|
+
for path in root.rglob("package-lock.json"):
|
|
65
|
+
rel = str(path.relative_to(root).as_posix())
|
|
66
|
+
if any(rel.startswith(e) or f"/{e}" in f"/{rel}" for e in excludes):
|
|
67
|
+
continue
|
|
68
|
+
out.append(path.parent)
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
def _parse(self, payload: dict, pkg_dir: Path) -> list[Finding]:
|
|
72
|
+
findings: list[Finding] = []
|
|
73
|
+
vulns = payload.get("vulnerabilities") or {}
|
|
74
|
+
manifest = pkg_dir / "package-lock.json"
|
|
75
|
+
for pkg_name, info in vulns.items():
|
|
76
|
+
severity_str = (info.get("severity") or "moderate").lower()
|
|
77
|
+
severity = _NPM_SEVERITY.get(severity_str, Severity.MEDIUM)
|
|
78
|
+
via = info.get("via") or []
|
|
79
|
+
advisories = [v for v in via if isinstance(v, dict)]
|
|
80
|
+
if not advisories:
|
|
81
|
+
# Indirect-only entry; collapse to a single finding.
|
|
82
|
+
findings.append(self._make_finding(
|
|
83
|
+
rule_id=f"npm_audit.{pkg_name}.indirect",
|
|
84
|
+
message=f"{pkg_name}: vulnerable via transitive dep.",
|
|
85
|
+
file_path=manifest, line_start=0, line_end=None, code_snippet=None,
|
|
86
|
+
severity=severity, confidence=Confidence.HIGH,
|
|
87
|
+
category=Category.DEPENDENCIES,
|
|
88
|
+
))
|
|
89
|
+
continue
|
|
90
|
+
for adv in advisories:
|
|
91
|
+
rule_id = f"npm_audit.{adv.get('source') or adv.get('name') or pkg_name}"
|
|
92
|
+
msg = adv.get("title") or adv.get("name") or pkg_name
|
|
93
|
+
url = adv.get("url") or ""
|
|
94
|
+
findings.append(self._make_finding(
|
|
95
|
+
rule_id=rule_id,
|
|
96
|
+
message=f"{pkg_name}: {msg} {url}".strip(),
|
|
97
|
+
file_path=manifest, line_start=0, line_end=None, code_snippet=None,
|
|
98
|
+
severity=severity, confidence=Confidence.HIGH,
|
|
99
|
+
category=Category.DEPENDENCIES,
|
|
100
|
+
))
|
|
101
|
+
return findings
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""OSV-Scanner — multi-ecosystem SCA via osv.dev.
|
|
2
|
+
|
|
3
|
+
Invocation:
|
|
4
|
+
osv-scanner --format=json --recursive <target>
|
|
5
|
+
|
|
6
|
+
Output: JSON with `results[].packages[].vulnerabilities[]` each carrying
|
|
7
|
+
id (CVE / GHSA / OSV-), severity, summary, references.
|
|
8
|
+
|
|
9
|
+
Overlaps with pip-audit + npm-audit by design; the fingerprint dedupe
|
|
10
|
+
across `(canonical_cwe, file_path, code_snippet)` ensures we don't
|
|
11
|
+
double-count the same upstream advisory.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from secure_code_audit.config import Config
|
|
19
|
+
from secure_code_audit.findings import Category, Confidence, Finding, Severity
|
|
20
|
+
from secure_code_audit.scanners.base import Scanner
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_OSV_SEVERITY: dict[str, Severity] = {
|
|
24
|
+
"CRITICAL": Severity.CRITICAL,
|
|
25
|
+
"HIGH": Severity.HIGH,
|
|
26
|
+
"MODERATE": Severity.MEDIUM,
|
|
27
|
+
"MEDIUM": Severity.MEDIUM,
|
|
28
|
+
"LOW": Severity.LOW,
|
|
29
|
+
"UNKNOWN": Severity.MEDIUM,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OsvScanner(Scanner):
|
|
34
|
+
name = "osv_scanner"
|
|
35
|
+
binary = "osv-scanner"
|
|
36
|
+
default_category = Category.DEPENDENCIES
|
|
37
|
+
|
|
38
|
+
def run(self, target: Path, config: Config) -> list[Finding]:
|
|
39
|
+
if not self.is_available():
|
|
40
|
+
return [self._unavailable_finding(target)]
|
|
41
|
+
|
|
42
|
+
sc_cfg = self.cfg(config)
|
|
43
|
+
args = [
|
|
44
|
+
self.binary,
|
|
45
|
+
"--format=json", "--recursive",
|
|
46
|
+
str(target),
|
|
47
|
+
]
|
|
48
|
+
args.extend(sc_cfg.extra_args)
|
|
49
|
+
|
|
50
|
+
# osv-scanner exits 1 on findings, 0 on clean, 127 on bad usage,
|
|
51
|
+
# 128 on internal error.
|
|
52
|
+
r = self._exec(args, cwd=target, timeout_seconds=sc_cfg.timeout_seconds,
|
|
53
|
+
allowed_exits=(0, 1))
|
|
54
|
+
if r.returncode == 124:
|
|
55
|
+
return [self._make_finding(
|
|
56
|
+
rule_id=f"{self.name}.tool_timeout",
|
|
57
|
+
message=f"osv-scanner timed out: {r.stderr[:200]}",
|
|
58
|
+
file_path=target, line_start=0, line_end=None, code_snippet=None,
|
|
59
|
+
severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
|
|
60
|
+
)]
|
|
61
|
+
if not r.stdout.strip():
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
payload = json.loads(r.stdout)
|
|
66
|
+
except json.JSONDecodeError:
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
return self._parse(payload, target)
|
|
70
|
+
|
|
71
|
+
def _parse(self, payload: dict, target: Path) -> list[Finding]:
|
|
72
|
+
out: list[Finding] = []
|
|
73
|
+
for result in payload.get("results", []):
|
|
74
|
+
source = (result.get("source") or {}).get("path") or ""
|
|
75
|
+
file_path = Path(source) if source else target
|
|
76
|
+
for pkg_block in result.get("packages", []):
|
|
77
|
+
pkg = pkg_block.get("package") or {}
|
|
78
|
+
pkg_name = pkg.get("name", "?")
|
|
79
|
+
pkg_ver = pkg.get("version", "?")
|
|
80
|
+
for vuln in pkg_block.get("vulnerabilities", []) or []:
|
|
81
|
+
vid = str(vuln.get("id") or "UNKNOWN")
|
|
82
|
+
summary = (vuln.get("summary") or "").strip()[:300]
|
|
83
|
+
severity_str = self._extract_severity(vuln)
|
|
84
|
+
severity = _OSV_SEVERITY.get(severity_str, Severity.MEDIUM)
|
|
85
|
+
out.append(self._make_finding(
|
|
86
|
+
rule_id=f"osv_scanner.{vid}",
|
|
87
|
+
message=f"{pkg_name} {pkg_ver} — {vid}: {summary}",
|
|
88
|
+
file_path=file_path,
|
|
89
|
+
line_start=0, line_end=None, code_snippet=None,
|
|
90
|
+
severity=severity,
|
|
91
|
+
confidence=Confidence.HIGH,
|
|
92
|
+
category=Category.DEPENDENCIES,
|
|
93
|
+
))
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
def _extract_severity(self, vuln: dict) -> str:
|
|
97
|
+
"""OSV format puts severity in two places: a top-level
|
|
98
|
+
`database_specific.severity` (string) and a `severity[]` array
|
|
99
|
+
with CVSS vectors. Prefer the string when present."""
|
|
100
|
+
ds = (vuln.get("database_specific") or {}).get("severity")
|
|
101
|
+
if isinstance(ds, str):
|
|
102
|
+
return ds.upper()
|
|
103
|
+
sev_arr = vuln.get("severity") or []
|
|
104
|
+
if isinstance(sev_arr, list) and sev_arr:
|
|
105
|
+
# CVSS string in score; we don't parse the vector — fall back
|
|
106
|
+
# to MEDIUM as a safe default.
|
|
107
|
+
return "MEDIUM"
|
|
108
|
+
return "UNKNOWN"
|