ci: enforce complete security audit coverage
This commit is contained in:
@@ -12,7 +12,7 @@ jobs:
|
|||||||
security-audit:
|
security-audit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
SECURITY_AUDIT_MODE: ci
|
SECURITY_AUDIT_MODE: full
|
||||||
SECURITY_AUDIT_SCOPE: govoplan
|
SECURITY_AUDIT_SCOPE: govoplan
|
||||||
SECURITY_AUDIT_FAIL_ON_FINDINGS: "0"
|
SECURITY_AUDIT_FAIL_ON_FINDINGS: "0"
|
||||||
SECURITY_AUDIT_REQUIRE_TOOLS: "1"
|
SECURITY_AUDIT_REQUIRE_TOOLS: "1"
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ accept findings, but scanner execution errors and malformed JSON/SARIF reports
|
|||||||
always fail the run. The manifest lists the expected, present, and missing
|
always fail the run. The manifest lists the expected, present, and missing
|
||||||
reports for that invocation. Validation and checksums use that explicit set, so
|
reports for that invocation. Validation and checksums use that explicit set, so
|
||||||
reusing a report directory cannot make stale output look like part of a new run.
|
reusing a report directory cannot make stale output look like part of a new run.
|
||||||
|
It also contains `coverage_status` and structured `scanner_coverage` entries.
|
||||||
|
Every required scanner is recorded as `no-findings`, `findings`,
|
||||||
|
`scanner-failure`, or `skipped`; this makes an incomplete local run visible
|
||||||
|
without treating it as a clean audit.
|
||||||
|
|
||||||
The wrapper tags the toolbox image by a fingerprint of the Dockerfile,
|
The wrapper tags the toolbox image by a fingerprint of the Dockerfile,
|
||||||
`requirements-audit.txt`, and the Semgrep smoke-test inputs. If those inputs have
|
`requirements-audit.txt`, and the Semgrep smoke-test inputs. If those inputs have
|
||||||
@@ -90,6 +94,12 @@ tools/checks/security-audit/run.sh --mode quick --scope current --update --build
|
|||||||
- `ci`: quick plus Semgrep public registry rulesets, Trivy, pip-audit, npm audit.
|
- `ci`: quick plus Semgrep public registry rulesets, Trivy, pip-audit, npm audit.
|
||||||
- `full`: ci plus OSV-Scanner, jscpd, Radon, and Xenon.
|
- `full`: ci plus OSV-Scanner, jscpd, Radon, and Xenon.
|
||||||
|
|
||||||
|
The Gitea workflow uses `full` mode so its coverage contract includes every
|
||||||
|
scanner above. Missing scanners fail strict runs and all Actions runs, even
|
||||||
|
while actual findings remain report-only. Local report-only runs may finish
|
||||||
|
with missing tools for diagnostics, but their manifest is marked
|
||||||
|
`coverage_status: incomplete`.
|
||||||
|
|
||||||
Semgrep and Trivy are invoked with finding-sensitive exit codes. Their exit 1
|
Semgrep and Trivy are invoked with finding-sensitive exit codes. Their exit 1
|
||||||
is therefore a finding under the wrapper contract; higher exit codes, missing
|
is therefore a finding under the wrapper contract; higher exit codes, missing
|
||||||
output, invalid JSON/SARIF, and scanner error payloads are execution failures.
|
output, invalid JSON/SARIF, and scanner error payloads are execution failures.
|
||||||
@@ -103,7 +113,7 @@ baseline.
|
|||||||
|
|
||||||
## Gating
|
## Gating
|
||||||
|
|
||||||
The initial Gitea workflow runs in report-only mode:
|
The Gitea workflow currently runs findings in report-only mode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SECURITY_AUDIT_FAIL_ON_FINDINGS=0
|
SECURITY_AUDIT_FAIL_ON_FINDINGS=0
|
||||||
@@ -119,7 +129,7 @@ SECURITY_AUDIT_FAIL_ON_FINDINGS=1
|
|||||||
or run locally with:
|
or run locally with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/checks/security-audit/run.sh --mode ci --scope current --strict
|
tools/checks/security-audit/run.sh --mode full --scope govoplan --strict
|
||||||
```
|
```
|
||||||
|
|
||||||
## Audit Burndown Workflow
|
## Audit Burndown Workflow
|
||||||
|
|||||||
@@ -253,6 +253,12 @@ class SecurityAuditWrapperTests(unittest.TestCase):
|
|||||||
self.assertEqual(0, manifest["overall_status"])
|
self.assertEqual(0, manifest["overall_status"])
|
||||||
self.assertEqual(1, manifest["finding_status"])
|
self.assertEqual(1, manifest["finding_status"])
|
||||||
self.assertEqual(0, manifest["execution_error_status"])
|
self.assertEqual(0, manifest["execution_error_status"])
|
||||||
|
self.assertEqual("complete", manifest["coverage_status"])
|
||||||
|
scanner_results = {
|
||||||
|
result["id"]: result["status"]
|
||||||
|
for result in manifest["scanner_coverage"]["results"]
|
||||||
|
}
|
||||||
|
self.assertEqual("findings", scanner_results["semgrep"])
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"Semgrep SAST\t1\tfindings", (reports / "step-status.tsv").read_text()
|
"Semgrep SAST\t1\tfindings", (reports / "step-status.tsv").read_text()
|
||||||
)
|
)
|
||||||
@@ -312,6 +318,86 @@ class SecurityAuditWrapperTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(1, self._manifest(invalid_reports)["execution_error_status"])
|
self.assertEqual(1, self._manifest(invalid_reports)["execution_error_status"])
|
||||||
|
|
||||||
|
def test_missing_tool_is_machine_readable_and_strictly_enforced(self) -> None:
|
||||||
|
gitleaks_stub = self.stub_bin / "gitleaks"
|
||||||
|
disabled_stub = self.stub_bin / "gitleaks.disabled"
|
||||||
|
gitleaks_stub.rename(disabled_stub)
|
||||||
|
try:
|
||||||
|
result, reports = self._run(
|
||||||
|
"--report-only",
|
||||||
|
SECURITY_AUDIT_REQUIRE_TOOLS="0",
|
||||||
|
CI="false",
|
||||||
|
GITEA_ACTIONS="false",
|
||||||
|
)
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
manifest = self._manifest(reports)
|
||||||
|
self.assertEqual("incomplete", manifest["coverage_status"])
|
||||||
|
self.assertEqual(["gitleaks"], manifest["scanner_coverage"]["incomplete"])
|
||||||
|
scanner_results = {
|
||||||
|
item["id"]: item for item in manifest["scanner_coverage"]["results"]
|
||||||
|
}
|
||||||
|
self.assertEqual("skipped", scanner_results["gitleaks"]["status"])
|
||||||
|
self.assertEqual(127, scanner_results["gitleaks"]["exit_code"])
|
||||||
|
self.assertIn(
|
||||||
|
"gitleaks\tmissing",
|
||||||
|
(reports / "tool-versions.txt").read_text(encoding="utf-8"),
|
||||||
|
)
|
||||||
|
|
||||||
|
strict_result, strict_reports = self._run(
|
||||||
|
"--strict",
|
||||||
|
SECURITY_AUDIT_REQUIRE_TOOLS="0",
|
||||||
|
CI="false",
|
||||||
|
GITEA_ACTIONS="false",
|
||||||
|
)
|
||||||
|
self.assertEqual(1, strict_result.returncode)
|
||||||
|
self.assertEqual(
|
||||||
|
"incomplete",
|
||||||
|
self._manifest(strict_reports)["coverage_status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
ci_result, ci_reports = self._run(
|
||||||
|
"--report-only",
|
||||||
|
SECURITY_AUDIT_REQUIRE_TOOLS="0",
|
||||||
|
CI="true",
|
||||||
|
GITEA_ACTIONS="false",
|
||||||
|
)
|
||||||
|
self.assertEqual(1, ci_result.returncode)
|
||||||
|
self.assertEqual(
|
||||||
|
"incomplete",
|
||||||
|
self._manifest(ci_reports)["coverage_status"],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
disabled_stub.rename(gitleaks_stub)
|
||||||
|
|
||||||
|
def test_full_mode_records_every_required_scanner(self) -> None:
|
||||||
|
self._install_full_mode_stubs()
|
||||||
|
|
||||||
|
result, reports = self._run("--report-only", mode="full")
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
manifest = self._manifest(reports)
|
||||||
|
self.assertEqual("complete", manifest["coverage_status"])
|
||||||
|
required = manifest["scanner_coverage"]["required"]
|
||||||
|
results = manifest["scanner_coverage"]["results"]
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"semgrep",
|
||||||
|
"bandit",
|
||||||
|
"ruff-security",
|
||||||
|
"gitleaks",
|
||||||
|
"trivy",
|
||||||
|
"pip-audit",
|
||||||
|
"npm-audit",
|
||||||
|
"osv-scanner",
|
||||||
|
"jscpd",
|
||||||
|
"radon",
|
||||||
|
"xenon",
|
||||||
|
},
|
||||||
|
set(required),
|
||||||
|
)
|
||||||
|
self.assertEqual(set(required), {item["id"] for item in results})
|
||||||
|
self.assertTrue(all(item["status"] == "no-findings" for item in results))
|
||||||
|
|
||||||
def test_workspace_mutation_invalidates_the_audit(self) -> None:
|
def test_workspace_mutation_invalidates_the_audit(self) -> None:
|
||||||
result, reports = self._run(
|
result, reports = self._run(
|
||||||
"--report-only",
|
"--report-only",
|
||||||
@@ -498,6 +584,7 @@ class SecurityAuditContainerRunnerTests(unittest.TestCase):
|
|||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
self.assertEqual(expected_roots, repository_roots)
|
self.assertEqual(expected_roots, repository_roots)
|
||||||
|
self.assertIn("SECURITY_AUDIT_REQUIRE_TOOLS=1", run_command)
|
||||||
|
|
||||||
def test_non_actions_runner_keeps_the_scoped_bind_mount(self) -> None:
|
def test_non_actions_runner_keeps_the_scoped_bind_mount(self) -> None:
|
||||||
result, commands = self._run(scope="govoplan")
|
result, commands = self._run(scope="govoplan")
|
||||||
|
|||||||
@@ -87,6 +87,37 @@ for flag_name in FAIL_ON_FINDINGS REQUIRE_TOOLS; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# A strict audit is only meaningful when every scanner in the selected mode is
|
||||||
|
# available. CI must enforce the same coverage even while findings are
|
||||||
|
# temporarily report-only.
|
||||||
|
if [[ "$FAIL_ON_FINDINGS" == "1" \
|
||||||
|
|| "${CI:-false}" == "true" \
|
||||||
|
|| "${GITEA_ACTIONS:-false}" == "true" ]]; then
|
||||||
|
REQUIRE_TOOLS=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
declare -a REQUIRED_SCANNERS=(
|
||||||
|
semgrep
|
||||||
|
bandit
|
||||||
|
ruff-security
|
||||||
|
gitleaks
|
||||||
|
)
|
||||||
|
if [[ "$MODE" != "quick" ]]; then
|
||||||
|
REQUIRED_SCANNERS+=(
|
||||||
|
trivy
|
||||||
|
pip-audit
|
||||||
|
npm-audit
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
if [[ "$MODE" == "full" ]]; then
|
||||||
|
REQUIRED_SCANNERS+=(
|
||||||
|
osv-scanner
|
||||||
|
jscpd
|
||||||
|
radon
|
||||||
|
xenon
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
declare -a REPOS=()
|
declare -a REPOS=()
|
||||||
if [[ "$SCOPE" == "govoplan" ]]; then
|
if [[ "$SCOPE" == "govoplan" ]]; then
|
||||||
REPOS_ROOT="${GOVOPLAN_REPOS_ROOT:-$(dirname "$ROOT")}"
|
REPOS_ROOT="${GOVOPLAN_REPOS_ROOT:-$(dirname "$ROOT")}"
|
||||||
@@ -138,12 +169,14 @@ overall_status=0
|
|||||||
finding_status=0
|
finding_status=0
|
||||||
missing_status=0
|
missing_status=0
|
||||||
execution_status=0
|
execution_status=0
|
||||||
|
coverage_complete=true
|
||||||
audit_started_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
audit_started_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||||
audit_completed_at=""
|
audit_completed_at=""
|
||||||
workspace_unchanged="unknown"
|
workspace_unchanged="unknown"
|
||||||
audit_toolbox_fingerprint="${SECURITY_AUDIT_TOOLBOX_FINGERPRINT:-direct-host}"
|
audit_toolbox_fingerprint="${SECURITY_AUDIT_TOOLBOX_FINGERPRINT:-direct-host}"
|
||||||
declare -A REPORT_FILES=()
|
declare -A REPORT_FILES=()
|
||||||
declare -A MACHINE_REPORTS=()
|
declare -A MACHINE_REPORTS=()
|
||||||
|
declare -A SCANNER_TERMINAL_STATUSES=()
|
||||||
|
|
||||||
register_report() {
|
register_report() {
|
||||||
local path="$1"
|
local path="$1"
|
||||||
@@ -169,7 +202,9 @@ prepare_machine_report() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
run_step() {
|
run_step_internal() {
|
||||||
|
local scanner_id="$1"
|
||||||
|
shift
|
||||||
local name="$1"
|
local name="$1"
|
||||||
local exit_contract="$2"
|
local exit_contract="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -178,10 +213,12 @@ run_step() {
|
|||||||
"$@"
|
"$@"
|
||||||
local status=$?
|
local status=$?
|
||||||
local outcome="passed"
|
local outcome="passed"
|
||||||
|
local scanner_outcome="no-findings"
|
||||||
if [[ "$status" -eq 0 ]]; then
|
if [[ "$status" -eq 0 ]]; then
|
||||||
:
|
:
|
||||||
elif [[ "$exit_contract" == "findings-exit-one" && "$status" -eq 1 ]]; then
|
elif [[ "$exit_contract" == "findings-exit-one" && "$status" -eq 1 ]]; then
|
||||||
outcome="findings"
|
outcome="findings"
|
||||||
|
scanner_outcome="findings"
|
||||||
echo "Audit step reported findings: $name (exit $status)" >&2
|
echo "Audit step reported findings: $name (exit $status)" >&2
|
||||||
finding_status=1
|
finding_status=1
|
||||||
if [[ "$FAIL_ON_FINDINGS" == "1" ]]; then
|
if [[ "$FAIL_ON_FINDINGS" == "1" ]]; then
|
||||||
@@ -189,21 +226,77 @@ run_step() {
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
outcome="execution-error"
|
outcome="execution-error"
|
||||||
|
scanner_outcome="scanner-failure"
|
||||||
echo "Audit step failed to execute successfully: $name (exit $status)" >&2
|
echo "Audit step failed to execute successfully: $name (exit $status)" >&2
|
||||||
execution_status=1
|
execution_status=1
|
||||||
overall_status=1
|
overall_status=1
|
||||||
fi
|
fi
|
||||||
printf '%s\t%s\t%s\n' "$name" "$status" "$outcome" >> "$REPORTS_DIR/step-status.tsv"
|
printf '%s\t%s\t%s\n' "$name" "$status" "$outcome" >> "$REPORTS_DIR/step-status.tsv"
|
||||||
|
if [[ -n "$scanner_id" ]]; then
|
||||||
|
SCANNER_TERMINAL_STATUSES["$scanner_id"]="$scanner_outcome"
|
||||||
|
if [[ "$scanner_outcome" == "scanner-failure" ]]; then
|
||||||
|
coverage_complete=false
|
||||||
|
fi
|
||||||
|
printf '%s\t%s\t%s\t%s\n' \
|
||||||
|
"$scanner_id" \
|
||||||
|
"$name" \
|
||||||
|
"$status" \
|
||||||
|
"$scanner_outcome" \
|
||||||
|
>> "$REPORTS_DIR/scanner-status.tsv"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_step() {
|
||||||
|
run_step_internal "" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_scanner_step() {
|
||||||
|
local scanner_id="$1"
|
||||||
|
shift
|
||||||
|
run_step_internal "$scanner_id" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
skip_or_fail_missing() {
|
skip_or_fail_missing() {
|
||||||
local tool="$1"
|
local scanner_id="$1"
|
||||||
|
local tool="$2"
|
||||||
echo "Skipping $tool: command not found. Use tools/checks/security-audit/run.sh for the containerized toolbox." >&2
|
echo "Skipping $tool: command not found. Use tools/checks/security-audit/run.sh for the containerized toolbox." >&2
|
||||||
missing_status=1
|
missing_status=1
|
||||||
|
coverage_complete=false
|
||||||
|
SCANNER_TERMINAL_STATUSES["$scanner_id"]="skipped"
|
||||||
if [[ "$REQUIRE_TOOLS" == "1" ]]; then
|
if [[ "$REQUIRE_TOOLS" == "1" ]]; then
|
||||||
overall_status=1
|
overall_status=1
|
||||||
fi
|
fi
|
||||||
printf '%s\t127\tmissing\n' "$tool" >> "$REPORTS_DIR/step-status.tsv"
|
printf '%s\t127\tmissing\n' "$tool" >> "$REPORTS_DIR/step-status.tsv"
|
||||||
|
printf '%s\t%s\t127\tskipped\n' \
|
||||||
|
"$scanner_id" \
|
||||||
|
"$tool" \
|
||||||
|
>> "$REPORTS_DIR/scanner-status.tsv"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_scanner_status_contract() {
|
||||||
|
local scanner_id
|
||||||
|
local scanner_status
|
||||||
|
local invalid_status=0
|
||||||
|
for scanner_id in "${REQUIRED_SCANNERS[@]}"; do
|
||||||
|
scanner_status="${SCANNER_TERMINAL_STATUSES[$scanner_id]:-}"
|
||||||
|
case "$scanner_status" in
|
||||||
|
no-findings|findings) ;;
|
||||||
|
scanner-failure|skipped)
|
||||||
|
coverage_complete=false
|
||||||
|
;;
|
||||||
|
"")
|
||||||
|
echo "Required scanner did not record a terminal status: $scanner_id" >&2
|
||||||
|
coverage_complete=false
|
||||||
|
invalid_status=2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Required scanner recorded an invalid status: $scanner_id ($scanner_status)" >&2
|
||||||
|
coverage_complete=false
|
||||||
|
invalid_status=2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
return "$invalid_status"
|
||||||
}
|
}
|
||||||
|
|
||||||
write_manifest() {
|
write_manifest() {
|
||||||
@@ -229,7 +322,10 @@ write_manifest() {
|
|||||||
"$finding_status" \
|
"$finding_status" \
|
||||||
"$execution_status" \
|
"$execution_status" \
|
||||||
"$missing_status" \
|
"$missing_status" \
|
||||||
|
"$coverage_complete" \
|
||||||
"$audit_toolbox_fingerprint" \
|
"$audit_toolbox_fingerprint" \
|
||||||
|
"${#REQUIRED_SCANNERS[@]}" \
|
||||||
|
"${REQUIRED_SCANNERS[@]}" \
|
||||||
"${#REPOS[@]}" \
|
"${#REPOS[@]}" \
|
||||||
"${REPOS[@]}" \
|
"${REPOS[@]}" \
|
||||||
"${#present_reports[@]}" \
|
"${#present_reports[@]}" \
|
||||||
@@ -254,16 +350,51 @@ import sys
|
|||||||
finding_status,
|
finding_status,
|
||||||
execution_status,
|
execution_status,
|
||||||
missing_status,
|
missing_status,
|
||||||
|
coverage_complete,
|
||||||
toolbox_fingerprint,
|
toolbox_fingerprint,
|
||||||
repository_count,
|
|
||||||
*remaining,
|
*remaining,
|
||||||
) = sys.argv[1:]
|
) = sys.argv[1:]
|
||||||
|
required_scanner_count = int(remaining[0])
|
||||||
|
required_scanners = remaining[1 : required_scanner_count + 1]
|
||||||
|
remaining = remaining[required_scanner_count + 1 :]
|
||||||
|
repository_count = remaining[0]
|
||||||
|
remaining = remaining[1:]
|
||||||
repo_count = int(repository_count)
|
repo_count = int(repository_count)
|
||||||
repositories = remaining[:repo_count]
|
repositories = remaining[:repo_count]
|
||||||
remaining = remaining[repo_count:]
|
remaining = remaining[repo_count:]
|
||||||
report_count = int(remaining[0])
|
report_count = int(remaining[0])
|
||||||
reports = remaining[1 : report_count + 1]
|
reports = remaining[1 : report_count + 1]
|
||||||
expected_reports = remaining[report_count + 1 :]
|
expected_reports = remaining[report_count + 1 :]
|
||||||
|
scanner_results = []
|
||||||
|
scanner_status_path = Path(manifest_path).with_name("scanner-status.tsv")
|
||||||
|
if scanner_status_path.is_file():
|
||||||
|
for line_number, raw_line in enumerate(
|
||||||
|
scanner_status_path.read_text(encoding="utf-8").splitlines(),
|
||||||
|
start=1,
|
||||||
|
):
|
||||||
|
fields = raw_line.split("\t")
|
||||||
|
if len(fields) != 4:
|
||||||
|
raise SystemExit(
|
||||||
|
f"{scanner_status_path}:{line_number}: expected four fields"
|
||||||
|
)
|
||||||
|
scanner_id, name, exit_code, status = fields
|
||||||
|
scanner_results.append(
|
||||||
|
{
|
||||||
|
"id": scanner_id,
|
||||||
|
"name": name,
|
||||||
|
"exit_code": int(exit_code),
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result_ids = {result["id"] for result in scanner_results}
|
||||||
|
incomplete_scanners = sorted(
|
||||||
|
{
|
||||||
|
result["id"]
|
||||||
|
for result in scanner_results
|
||||||
|
if result["status"] in {"scanner-failure", "skipped"}
|
||||||
|
}
|
||||||
|
| (set(required_scanners) - result_ids)
|
||||||
|
)
|
||||||
payload = {
|
payload = {
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"scope": scope,
|
"scope": scope,
|
||||||
@@ -276,6 +407,12 @@ payload = {
|
|||||||
"finding_status": int(finding_status),
|
"finding_status": int(finding_status),
|
||||||
"execution_error_status": int(execution_status),
|
"execution_error_status": int(execution_status),
|
||||||
"missing_tool_status": int(missing_status),
|
"missing_tool_status": int(missing_status),
|
||||||
|
"coverage_status": "complete" if coverage_complete == "true" else "incomplete",
|
||||||
|
"scanner_coverage": {
|
||||||
|
"required": required_scanners,
|
||||||
|
"results": scanner_results,
|
||||||
|
"incomplete": incomplete_scanners,
|
||||||
|
},
|
||||||
"tool_versions": "tool-versions.txt",
|
"tool_versions": "tool-versions.txt",
|
||||||
"workspace_state_start": "workspace-state-start.tsv",
|
"workspace_state_start": "workspace-state-start.tsv",
|
||||||
"workspace_state_end": "workspace-state-end.tsv",
|
"workspace_state_end": "workspace-state-end.tsv",
|
||||||
@@ -350,7 +487,10 @@ write_tool_versions() {
|
|||||||
: > "$destination"
|
: > "$destination"
|
||||||
local tool
|
local tool
|
||||||
for tool in semgrep bandit ruff gitleaks trivy pip-audit npm osv-scanner jscpd radon xenon; do
|
for tool in semgrep bandit ruff gitleaks trivy pip-audit npm osv-scanner jscpd radon xenon; do
|
||||||
has_tool "$tool" || continue
|
if ! has_tool "$tool"; then
|
||||||
|
printf '%s\t%s\n' "$tool" "missing" >> "$destination"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
if [[ "$tool" == "gitleaks" ]]; then
|
if [[ "$tool" == "gitleaks" ]]; then
|
||||||
version="$("$tool" version 2>&1)"
|
version="$("$tool" version 2>&1)"
|
||||||
else
|
else
|
||||||
@@ -599,9 +739,9 @@ run_pip_audit_manifests() {
|
|||||||
report_path="$REPORTS_DIR/pip-audit-$repo_name-$manifest_name.json"
|
report_path="$REPORTS_DIR/pip-audit-$repo_name-$manifest_name.json"
|
||||||
prepare_machine_report "$report_path" || return 2
|
prepare_machine_report "$report_path" || return 2
|
||||||
# Requirements may contain project-relative entries such as `.[server]`.
|
# Requirements may contain project-relative entries such as `.[server]`.
|
||||||
# Resolve them from the owning repository instead of the meta-repository.
|
# Resolve them from the directory containing the manifest.
|
||||||
(
|
(
|
||||||
cd "$repo" &&
|
cd "$(dirname "$requirements_file")" &&
|
||||||
pip-audit \
|
pip-audit \
|
||||||
-r "$(basename "$requirements_file")" \
|
-r "$(basename "$requirements_file")" \
|
||||||
--progress-spinner off \
|
--progress-spinner off \
|
||||||
@@ -611,10 +751,17 @@ run_pip_audit_manifests() {
|
|||||||
accumulate_exit_status status "$?"
|
accumulate_exit_status status "$?"
|
||||||
done < <(
|
done < <(
|
||||||
find "$repo" \
|
find "$repo" \
|
||||||
-maxdepth 1 \
|
-type d \( \
|
||||||
-type f \
|
-name .git \
|
||||||
-name 'requirements*.txt' \
|
-o -name .venv \
|
||||||
-print0 |
|
-o -name node_modules \
|
||||||
|
-o -name dist \
|
||||||
|
-o -name build \
|
||||||
|
-o -name runtime \
|
||||||
|
-o -name audit-reports \
|
||||||
|
-o -name '.*-test-build' \
|
||||||
|
\) -prune \
|
||||||
|
-o -type f -name 'requirements*.txt' -print0 |
|
||||||
sort -z
|
sort -z
|
||||||
)
|
)
|
||||||
done
|
done
|
||||||
@@ -649,11 +796,17 @@ run_npm_audit_manifests() {
|
|||||||
accumulate_exit_status status "$?"
|
accumulate_exit_status status "$?"
|
||||||
done < <(
|
done < <(
|
||||||
find "$repo" \
|
find "$repo" \
|
||||||
-maxdepth 3 \
|
-type d \( \
|
||||||
-type f \
|
-name .git \
|
||||||
-name package-lock.json \
|
-o -name .venv \
|
||||||
-not -path '*/node_modules/*' \
|
-o -name node_modules \
|
||||||
-print0 |
|
-o -name dist \
|
||||||
|
-o -name build \
|
||||||
|
-o -name runtime \
|
||||||
|
-o -name audit-reports \
|
||||||
|
-o -name '.*-test-build' \
|
||||||
|
\) -prune \
|
||||||
|
-o -type f -name package-lock.json -print0 |
|
||||||
sort -z
|
sort -z
|
||||||
)
|
)
|
||||||
done
|
done
|
||||||
@@ -718,6 +871,19 @@ run_jscpd() {
|
|||||||
--output "$REPORTS_DIR/jscpd" \
|
--output "$REPORTS_DIR/jscpd" \
|
||||||
--ignore "$ignored_path_pattern" \
|
--ignore "$ignored_path_pattern" \
|
||||||
"${REPOS[@]}"
|
"${REPOS[@]}"
|
||||||
|
local jscpd_status=$?
|
||||||
|
[[ "$jscpd_status" -eq 0 ]] || return 2
|
||||||
|
python3 - "$REPORTS_DIR/jscpd/jscpd-report.json" <<'PY'
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
report = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||||
|
raise SystemExit(1 if report.get("duplicates") else 0)
|
||||||
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
run_radon() {
|
run_radon() {
|
||||||
@@ -733,10 +899,12 @@ run_xenon() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
register_report "$REPORTS_DIR/step-status.tsv"
|
register_report "$REPORTS_DIR/step-status.tsv"
|
||||||
|
register_report "$REPORTS_DIR/scanner-status.tsv"
|
||||||
register_report "$REPORTS_DIR/tool-versions.txt"
|
register_report "$REPORTS_DIR/tool-versions.txt"
|
||||||
register_report "$REPORTS_DIR/workspace-state-start.tsv"
|
register_report "$REPORTS_DIR/workspace-state-start.tsv"
|
||||||
register_report "$REPORTS_DIR/workspace-state-end.tsv"
|
register_report "$REPORTS_DIR/workspace-state-end.tsv"
|
||||||
: > "$REPORTS_DIR/step-status.tsv"
|
: > "$REPORTS_DIR/step-status.tsv"
|
||||||
|
: > "$REPORTS_DIR/scanner-status.tsv"
|
||||||
if ! write_workspace_state "$REPORTS_DIR/workspace-state-start.tsv"; then
|
if ! write_workspace_state "$REPORTS_DIR/workspace-state-start.tsv"; then
|
||||||
echo "Could not capture the repository state before the audit." >&2
|
echo "Could not capture the repository state before the audit." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -744,75 +912,76 @@ fi
|
|||||||
run_step "Record audit tool versions" execution-only write_tool_versions
|
run_step "Record audit tool versions" execution-only write_tool_versions
|
||||||
|
|
||||||
if has_tool semgrep; then
|
if has_tool semgrep; then
|
||||||
run_step "Semgrep SAST" findings-exit-one run_semgrep
|
run_scanner_step semgrep "Semgrep SAST" findings-exit-one run_semgrep
|
||||||
else
|
else
|
||||||
skip_or_fail_missing semgrep
|
skip_or_fail_missing semgrep semgrep
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool bandit; then
|
if has_tool bandit; then
|
||||||
run_step "Bandit Python security scan" findings-exit-one run_bandit
|
run_scanner_step bandit "Bandit Python security scan" findings-exit-one run_bandit
|
||||||
else
|
else
|
||||||
skip_or_fail_missing bandit
|
skip_or_fail_missing bandit bandit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool ruff; then
|
if has_tool ruff; then
|
||||||
run_step "Ruff flake8-bandit security rules" findings-exit-one run_ruff_security
|
run_scanner_step ruff-security "Ruff flake8-bandit security rules" findings-exit-one run_ruff_security
|
||||||
else
|
else
|
||||||
skip_or_fail_missing ruff
|
skip_or_fail_missing ruff-security ruff
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool gitleaks; then
|
if has_tool gitleaks; then
|
||||||
run_step "Gitleaks secret scan" findings-exit-one run_gitleaks
|
run_scanner_step gitleaks "Gitleaks secret scan" findings-exit-one run_gitleaks
|
||||||
else
|
else
|
||||||
skip_or_fail_missing gitleaks
|
skip_or_fail_missing gitleaks gitleaks
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$MODE" != "quick" ]]; then
|
if [[ "$MODE" != "quick" ]]; then
|
||||||
if has_tool trivy; then
|
if has_tool trivy; then
|
||||||
run_step "Trivy filesystem vulnerability/secret/misconfig scan" findings-exit-one run_trivy
|
run_scanner_step trivy "Trivy filesystem vulnerability/secret/misconfig scan" findings-exit-one run_trivy
|
||||||
else
|
else
|
||||||
skip_or_fail_missing trivy
|
skip_or_fail_missing trivy trivy
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool pip-audit; then
|
if has_tool pip-audit; then
|
||||||
run_step "pip-audit requirements scan" findings-exit-one run_pip_audit_manifests
|
run_scanner_step pip-audit "pip-audit requirements scan" findings-exit-one run_pip_audit_manifests
|
||||||
else
|
else
|
||||||
skip_or_fail_missing pip-audit
|
skip_or_fail_missing pip-audit pip-audit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool npm; then
|
if has_tool npm; then
|
||||||
run_step "npm audit lockfile scan" findings-exit-one run_npm_audit_manifests
|
run_scanner_step npm-audit "npm audit lockfile scan" findings-exit-one run_npm_audit_manifests
|
||||||
else
|
else
|
||||||
skip_or_fail_missing npm
|
skip_or_fail_missing npm-audit npm
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$MODE" == "full" ]]; then
|
if [[ "$MODE" == "full" ]]; then
|
||||||
if has_tool osv-scanner; then
|
if has_tool osv-scanner; then
|
||||||
run_step "OSV-Scanner recursive dependency scan" findings-exit-one run_osv_scanner
|
run_scanner_step osv-scanner "OSV-Scanner recursive dependency scan" findings-exit-one run_osv_scanner
|
||||||
else
|
else
|
||||||
skip_or_fail_missing osv-scanner
|
skip_or_fail_missing osv-scanner osv-scanner
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool jscpd; then
|
if has_tool jscpd; then
|
||||||
run_step "jscpd duplicate code scan" execution-only run_jscpd
|
run_scanner_step jscpd "jscpd duplicate code scan" findings-exit-one run_jscpd
|
||||||
else
|
else
|
||||||
skip_or_fail_missing jscpd
|
skip_or_fail_missing jscpd jscpd
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool radon; then
|
if has_tool radon; then
|
||||||
run_step "Radon complexity report" execution-only run_radon
|
run_scanner_step radon "Radon complexity report" execution-only run_radon
|
||||||
else
|
else
|
||||||
skip_or_fail_missing radon
|
skip_or_fail_missing radon radon
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool xenon; then
|
if has_tool xenon; then
|
||||||
run_step "Xenon complexity threshold scan" findings-exit-one run_xenon
|
run_scanner_step xenon "Xenon complexity threshold scan" findings-exit-one run_xenon
|
||||||
else
|
else
|
||||||
skip_or_fail_missing xenon
|
skip_or_fail_missing xenon xenon
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
run_step "Validate required scanner coverage records" execution-only validate_scanner_status_contract
|
||||||
run_step "Validate machine-readable audit reports" execution-only validate_machine_reports
|
run_step "Validate machine-readable audit reports" execution-only validate_machine_reports
|
||||||
|
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ RUN python -m pip install --upgrade pip \
|
|||||||
&& osv-scanner --version \
|
&& osv-scanner --version \
|
||||||
&& trivy --version \
|
&& trivy --version \
|
||||||
&& jscpd --version \
|
&& jscpd --version \
|
||||||
&& pip-audit --progress-spinner off
|
&& pip-audit --progress-spinner off \
|
||||||
|
&& npm --version \
|
||||||
|
&& radon --version \
|
||||||
|
&& xenon --version
|
||||||
|
|
||||||
RUN mkdir -p /workspace \
|
RUN mkdir -p /workspace \
|
||||||
&& chmod 0777 /workspace
|
&& chmod 0777 /workspace
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ done
|
|||||||
REBUILD="${REBUILD:-0}"
|
REBUILD="${REBUILD:-0}"
|
||||||
UPDATE="${UPDATE:-0}"
|
UPDATE="${UPDATE:-0}"
|
||||||
BUILD_ONLY="${BUILD_ONLY:-0}"
|
BUILD_ONLY="${BUILD_ONLY:-0}"
|
||||||
|
REQUIRE_TOOLS="${SECURITY_AUDIT_REQUIRE_TOOLS:-0}"
|
||||||
|
if [[ "${CI:-false}" == "true" || "${GITEA_ACTIONS:-false}" == "true" ]]; then
|
||||||
|
REQUIRE_TOOLS=1
|
||||||
|
fi
|
||||||
|
|
||||||
declare -a DOCKER_CLI=()
|
declare -a DOCKER_CLI=()
|
||||||
DOCKER_LOCATION=""
|
DOCKER_LOCATION=""
|
||||||
@@ -196,7 +200,7 @@ fi
|
|||||||
-e SECURITY_AUDIT_MODE="$MODE" \
|
-e SECURITY_AUDIT_MODE="$MODE" \
|
||||||
-e SECURITY_AUDIT_REPORTS_DIR="$REPORTS_DIR" \
|
-e SECURITY_AUDIT_REPORTS_DIR="$REPORTS_DIR" \
|
||||||
-e SECURITY_AUDIT_FAIL_ON_FINDINGS="${SECURITY_AUDIT_FAIL_ON_FINDINGS:-0}" \
|
-e SECURITY_AUDIT_FAIL_ON_FINDINGS="${SECURITY_AUDIT_FAIL_ON_FINDINGS:-0}" \
|
||||||
-e SECURITY_AUDIT_REQUIRE_TOOLS="${SECURITY_AUDIT_REQUIRE_TOOLS:-0}" \
|
-e SECURITY_AUDIT_REQUIRE_TOOLS="$REQUIRE_TOOLS" \
|
||||||
-e SECURITY_AUDIT_TOOLBOX_FINGERPRINT="$IMAGE_FINGERPRINT" \
|
-e SECURITY_AUDIT_TOOLBOX_FINGERPRINT="$IMAGE_FINGERPRINT" \
|
||||||
"${EXTRA_ENV[@]}" \
|
"${EXTRA_ENV[@]}" \
|
||||||
"${WORKSPACE_MOUNT[@]}" \
|
"${WORKSPACE_MOUNT[@]}" \
|
||||||
|
|||||||
Reference in New Issue
Block a user