Skip to content

Commit 7037378

Browse files
authored
Merge branch 'main' into swarit/fix/enable-rc-config-feature-gates
2 parents 5235293 + 784b15a commit 7037378

27 files changed

Lines changed: 2207 additions & 137 deletions

.github/workflows/test-build.yml

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
name: Test Build (Binaries & MSIs)
2+
# run-name embeds the dispatcher-supplied correlation_id so a caller (e.g. the
3+
# integration-test Intune E2E) can locate the exact run it triggered: a
4+
# workflow_dispatch run's head_sha is the ref HEAD, not commit_id, so the SHA
5+
# alone cannot identify it.
6+
run-name: Test Build ${{ inputs.commit_id }} ${{ inputs.correlation_id }}
27

38
# Build test binaries and MSIs from an arbitrary commit, without cutting a
49
# release tag or touching the upstream release pipeline. Artifacts are
@@ -16,6 +21,10 @@ on:
1621
description: "Optional PR number to comment on with the artifact location"
1722
required: false
1823
type: string
24+
correlation_id:
25+
description: "Opaque marker echoed into run-name so a dispatcher can correlate this run (e.g. the integration-test Intune E2E)"
26+
required: false
27+
type: string
1928

2029
permissions: {}
2130

@@ -205,13 +214,98 @@ jobs:
205214
206215
Get-ChildItem dist -Filter "*.msi" | Format-Table Name, Length
207216
217+
# Pack the MSIs into .intunewin so the integration-test Intune E2E can test
218+
# a per-commit package identical in shape to a release (just unsigned).
219+
# Mirrors the pack in release.yml; no Sigstore signing / release upload here.
220+
- name: Download IntuneWinAppUtil.exe (pinned)
221+
shell: pwsh
222+
run: |
223+
# Microsoft/Microsoft-Win32-Content-Prep-Tool v1.8.7, commit-pinned raw
224+
# URL + canonical SHA-256 (same pin as release.yml), Authenticode-checked
225+
# in the next step.
226+
$ErrorActionPreference = 'Stop'
227+
$url = 'https://raw.githubusercontent.com/microsoft/Microsoft-Win32-Content-Prep-Tool/1d6cfcbdf8c28edc596337031f74df951f38f718/IntuneWinAppUtil.exe'
228+
$sha256 = 'c1ba45b5cb939e84af064bb7ff4b38fb3dfe33c8dc1078fd9b157672eae671f6'
229+
$dst = 'dist/tools/IntuneWinAppUtil.exe'
230+
New-Item -ItemType Directory -Path (Split-Path $dst) -Force | Out-Null
231+
Invoke-WebRequest -Uri $url -OutFile $dst -UseBasicParsing
232+
$actual = (Get-FileHash -Path $dst -Algorithm SHA256).Hash.ToLower()
233+
if ($actual -ne $sha256) {
234+
Write-Error "IntuneWinAppUtil.exe SHA-256 mismatch -- expected $sha256, got $actual"
235+
exit 1
236+
}
237+
Write-Host "IntuneWinAppUtil.exe SHA-256 verified: $actual"
238+
239+
- name: Verify IntuneWinAppUtil.exe Microsoft Authenticode signature
240+
shell: pwsh
241+
run: |
242+
$ErrorActionPreference = 'Stop'
243+
$sig = Get-AuthenticodeSignature 'dist/tools/IntuneWinAppUtil.exe'
244+
$subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '<none>' }
245+
Write-Host "IntuneWinAppUtil.exe: Status=$($sig.Status), Signer=$subject"
246+
if ($sig.Status -ne 'Valid') {
247+
Write-Error "IntuneWinAppUtil.exe signature status is $($sig.Status), expected Valid"
248+
exit 1
249+
}
250+
if ($subject -notlike '*Microsoft Corporation*') {
251+
Write-Error "IntuneWinAppUtil.exe not signed by Microsoft Corporation (subject: $subject)"
252+
exit 1
253+
}
254+
255+
- name: Pack .intunewin (x64 + arm64)
256+
shell: pwsh
257+
run: |
258+
# IntuneWinAppUtil takes a SOURCE FOLDER (-c) and a SETUP FILE within it
259+
# (-s); the setup file is install.cmd (not the MSI), with uninstall.cmd
260+
# and the MSI staged alongside -- identical to release.yml so the package
261+
# matches the real delivery path byte-for-byte (modulo signing).
262+
$ErrorActionPreference = 'Stop'
263+
$version = "${{ needs.build.outputs.version }}"
264+
$tool = 'dist/tools/IntuneWinAppUtil.exe'
265+
266+
foreach ($arch in @('x64', 'arm64')) {
267+
$msiName = "stepsecurity-dev-machine-guard-$version-$arch.msi"
268+
$stage = "dist/intunewin-staging-$arch"
269+
New-Item -ItemType Directory -Path $stage -Force | Out-Null
270+
Copy-Item -Path "dist/$msiName" -Destination $stage -Force
271+
Copy-Item -Path "packaging/windows/intune/install.cmd" -Destination $stage -Force
272+
Copy-Item -Path "packaging/windows/intune/uninstall.cmd" -Destination $stage -Force
273+
274+
& $tool -c $stage -s install.cmd -o dist -q
275+
if ($LASTEXITCODE -ne 0) {
276+
Write-Error "IntuneWinAppUtil.exe failed for $arch (exit $LASTEXITCODE)"
277+
exit 1
278+
}
279+
Remove-Item -Path $stage -Recurse -Force
280+
281+
# IntuneWinAppUtil names the output after the setup file
282+
# (install.intunewin); rename to the versioned, arch-tagged filename.
283+
$produced = "dist/install.intunewin"
284+
if (-not (Test-Path $produced)) {
285+
Write-Error "Expected output not found: $produced"
286+
exit 1
287+
}
288+
$final = "dist/stepsecurity-dev-machine-guard-$version-$arch.intunewin"
289+
if (Test-Path $final) { Remove-Item -Path $final -Force }
290+
Move-Item -Path $produced -Destination $final
291+
}
292+
293+
Get-ChildItem dist -Filter "*.intunewin" | Format-Table Name, Length
294+
208295
- name: Upload MSIs
209296
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
210297
with:
211298
name: windows-msis
212299
path: dist/*.msi
213300
if-no-files-found: error
214301

302+
- name: Upload .intunewin
303+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
304+
with:
305+
name: windows-intunewin
306+
path: dist/*.intunewin
307+
if-no-files-found: error
308+
215309
comment-on-pr:
216310
name: Comment on PR
217311
needs: [build, build-msi]

internal/cli/cli.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,22 @@ type Config struct {
7373
// Internal — not advertised in --help. Equivalent env var:
7474
// STEPSECURITY_OVERRIDE_GATE=1.
7575
OverrideGate bool
76+
77+
// RulesFile makes the malicious-file detection engine load its RuleSet
78+
// from a local JSON file instead of fetching it from the backend.
79+
// Dev-only — not advertised in --help. Equivalent env var:
80+
// STEPSECURITY_RULES_FILE=PATH. Lets the engine be exercised offline
81+
// (rules live only in the backend, so an offline run would otherwise scan
82+
// nothing). Zero production impact when unset.
83+
RulesFile string
84+
85+
// TelemetryOutFile makes an enterprise run write the assembled telemetry
86+
// Payload to a local JSON file and skip the S3 upload + run-status notify.
87+
// Dev-only — not advertised in --help. Equivalent env var:
88+
// STEPSECURITY_TELEMETRY_OUT=PATH. The dumped file is exactly what the
89+
// backend's process-uploaded sees after gunzip, so it doubles as a backend
90+
// ingestion fixture. Zero production impact when unset.
91+
TelemetryOutFile string
7692
}
7793

7894
// supportedHookAgents lists the agent names accepted by `hooks --agent <name>` and `_hook <agent> ...`.
@@ -253,6 +269,22 @@ func Parse(args []string) (*Config, error) {
253269
cfg.Verbose = true
254270
case arg == "--override-gate":
255271
cfg.OverrideGate = true
272+
case strings.HasPrefix(arg, "--rules-file="):
273+
cfg.RulesFile = strings.TrimPrefix(arg, "--rules-file=")
274+
case arg == "--rules-file":
275+
i++
276+
if i >= len(args) {
277+
return nil, fmt.Errorf("--rules-file requires a file path argument")
278+
}
279+
cfg.RulesFile = args[i]
280+
case strings.HasPrefix(arg, "--telemetry-out="):
281+
cfg.TelemetryOutFile = strings.TrimPrefix(arg, "--telemetry-out=")
282+
case arg == "--telemetry-out":
283+
i++
284+
if i >= len(args) {
285+
return nil, fmt.Errorf("--telemetry-out requires a file path argument")
286+
}
287+
cfg.TelemetryOutFile = args[i]
256288
case strings.HasPrefix(arg, "--log-level="):
257289
level := strings.ToLower(strings.TrimPrefix(arg, "--log-level="))
258290
switch level {
@@ -290,6 +322,16 @@ func Parse(args []string) (*Config, error) {
290322
return nil, fmt.Errorf("--npmrc, --pipconfig, --pnpmrc, --bunfig, and --yarnrc are mutually exclusive; pick one")
291323
}
292324

325+
// Env-var equivalents for the dev-only flags, so an installed
326+
// launchd/systemd unit need not change to exercise the offline harness.
327+
// An explicit flag wins over the env var.
328+
if cfg.RulesFile == "" {
329+
cfg.RulesFile = os.Getenv("STEPSECURITY_RULES_FILE")
330+
}
331+
if cfg.TelemetryOutFile == "" {
332+
cfg.TelemetryOutFile = os.Getenv("STEPSECURITY_TELEMETRY_OUT")
333+
}
334+
293335
// --install-dir= (explicit empty) disables file logging by routing
294336
// paths.Home() to "" globally. That conflicts with `install` /
295337
// `uninstall`, whose platform installers (systemd / launchd) call

internal/cli/cli_devflags_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package cli
2+
3+
import "testing"
4+
5+
func TestParse_RulesFileAndTelemetryOut(t *testing.T) {
6+
cfg, err := Parse([]string{"send-telemetry", "--rules-file=/tmp/rules.json", "--telemetry-out=/tmp/out.json"})
7+
if err != nil {
8+
t.Fatal(err)
9+
}
10+
if cfg.RulesFile != "/tmp/rules.json" {
11+
t.Errorf("RulesFile = %q", cfg.RulesFile)
12+
}
13+
if cfg.TelemetryOutFile != "/tmp/out.json" {
14+
t.Errorf("TelemetryOutFile = %q", cfg.TelemetryOutFile)
15+
}
16+
}
17+
18+
func TestParse_DevFlagsSeparateValue(t *testing.T) {
19+
cfg, err := Parse([]string{"--rules-file", "r.json", "--telemetry-out", "o.json"})
20+
if err != nil {
21+
t.Fatal(err)
22+
}
23+
if cfg.RulesFile != "r.json" || cfg.TelemetryOutFile != "o.json" {
24+
t.Errorf("got RulesFile=%q TelemetryOutFile=%q", cfg.RulesFile, cfg.TelemetryOutFile)
25+
}
26+
}
27+
28+
func TestParse_DevFlagsMissingValue(t *testing.T) {
29+
if _, err := Parse([]string{"--rules-file"}); err == nil {
30+
t.Error("--rules-file without value should error")
31+
}
32+
if _, err := Parse([]string{"--telemetry-out"}); err == nil {
33+
t.Error("--telemetry-out without value should error")
34+
}
35+
}
36+
37+
func TestParse_DevFlagsEnvVarFallback(t *testing.T) {
38+
t.Setenv("STEPSECURITY_RULES_FILE", "/env/rules.json")
39+
t.Setenv("STEPSECURITY_TELEMETRY_OUT", "/env/out.json")
40+
cfg, err := Parse([]string{})
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
if cfg.RulesFile != "/env/rules.json" || cfg.TelemetryOutFile != "/env/out.json" {
45+
t.Errorf("env fallback failed: RulesFile=%q TelemetryOutFile=%q", cfg.RulesFile, cfg.TelemetryOutFile)
46+
}
47+
}
48+
49+
func TestParse_FlagBeatsEnvVar(t *testing.T) {
50+
t.Setenv("STEPSECURITY_RULES_FILE", "/env/rules.json")
51+
cfg, err := Parse([]string{"--rules-file=/flag/rules.json"})
52+
if err != nil {
53+
t.Fatal(err)
54+
}
55+
if cfg.RulesFile != "/flag/rules.json" {
56+
t.Errorf("explicit flag should win over env var, got %q", cfg.RulesFile)
57+
}
58+
}

internal/detector/brew_test.go

Lines changed: 39 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package detector
22

33
import (
44
"context"
5+
"encoding/base64"
56
"testing"
67

78
"github.com/step-security/dev-machine-guard/internal/executor"
9+
"github.com/step-security/dev-machine-guard/internal/model"
810
"github.com/step-security/dev-machine-guard/internal/progress"
911
)
1012

@@ -76,56 +78,61 @@ func TestBrewDetector_ListCasks(t *testing.T) {
7678
}
7779
}
7880

79-
func TestBrewScanner_Formulae(t *testing.T) {
80-
mock := executor.NewMock()
81-
mock.SetPath("brew", "/opt/homebrew/bin/brew")
82-
mock.SetCommand("curl 8.4.0\ngit 2.43.0\n", "", 0, "brew", "list", "--formula", "--versions")
83-
84-
log := newTestLogger()
85-
scanner := NewBrewScanner(mock, log)
86-
result, ok := scanner.ScanFormulae(context.Background())
87-
88-
if !ok {
89-
t.Fatal("expected scan to succeed")
81+
func TestBrewScanner_FormulaeResult(t *testing.T) {
82+
scanner := NewBrewScanner(executor.NewMock(), newTestLogger())
83+
pkgs := []model.BrewPackage{
84+
{Name: "curl", Version: "8.4.0"},
85+
{Name: "git", Version: "2.43.0"},
9086
}
87+
result := scanner.FormulaeResult(pkgs)
88+
9189
if result.ScanType != "formulae" {
9290
t.Errorf("expected scan type formulae, got %s", result.ScanType)
9391
}
94-
if result.RawStdoutBase64 == "" {
95-
t.Error("expected non-empty base64 stdout")
96-
}
9792
if result.ExitCode != 0 {
9893
t.Errorf("expected exit code 0, got %d", result.ExitCode)
9994
}
95+
if result.LineCount != 2 {
96+
t.Errorf("expected line count 2, got %d", result.LineCount)
97+
}
98+
decoded, err := base64.StdEncoding.DecodeString(result.RawStdoutBase64)
99+
if err != nil {
100+
t.Fatalf("base64 decode failed: %v", err)
101+
}
102+
want := "curl 8.4.0\ngit 2.43.0\n"
103+
if string(decoded) != want {
104+
t.Errorf("stdout mismatch: got %q, want %q", string(decoded), want)
105+
}
100106
}
101107

102-
func TestBrewScanner_Casks(t *testing.T) {
103-
mock := executor.NewMock()
104-
mock.SetPath("brew", "/opt/homebrew/bin/brew")
105-
mock.SetCommand("firefox 120.0\ngoogle-chrome 120.0.6099.109\n", "", 0, "brew", "list", "--cask", "--versions")
106-
107-
log := newTestLogger()
108-
scanner := NewBrewScanner(mock, log)
109-
result, ok := scanner.ScanCasks(context.Background())
110-
111-
if !ok {
112-
t.Fatal("expected scan to succeed")
108+
func TestBrewScanner_CasksResult(t *testing.T) {
109+
scanner := NewBrewScanner(executor.NewMock(), newTestLogger())
110+
pkgs := []model.BrewPackage{
111+
{Name: "firefox", Version: "120.0"},
112+
{Name: "google-chrome", Version: "120.0.6099.109"},
113113
}
114+
result := scanner.CasksResult(pkgs)
115+
114116
if result.ScanType != "casks" {
115117
t.Errorf("expected scan type casks, got %s", result.ScanType)
116118
}
119+
if result.LineCount != 2 {
120+
t.Errorf("expected line count 2, got %d", result.LineCount)
121+
}
117122
if result.RawStdoutBase64 == "" {
118123
t.Error("expected non-empty base64 stdout")
119124
}
120125
}
121126

122-
func TestBrewScanner_NotInstalled(t *testing.T) {
123-
mock := executor.NewMock()
124-
log := newTestLogger()
125-
scanner := NewBrewScanner(mock, log)
127+
func TestBrewScanner_EmptyInput(t *testing.T) {
128+
scanner := NewBrewScanner(executor.NewMock(), newTestLogger())
129+
result := scanner.FormulaeResult(nil)
126130

127-
_, ok := scanner.ScanFormulae(context.Background())
128-
if ok {
129-
t.Error("expected scan to fail when brew is not installed")
131+
if result.LineCount != 0 {
132+
t.Errorf("expected line count 0, got %d", result.LineCount)
133+
}
134+
decoded, _ := base64.StdEncoding.DecodeString(result.RawStdoutBase64)
135+
if len(decoded) != 0 {
136+
t.Errorf("expected empty stdout, got %q", string(decoded))
130137
}
131138
}

0 commit comments

Comments
 (0)